---
title: "サービスロボット導入シミュレーション Log#12 | ロボットに地図を渡したら、詰まりが減った"
slug: serving-robot-map-planner
date: 2026-10-24
day: 374
meta_description: "Unity のファミレスの配膳ロボットに店の地図を渡し、道を自分で引いて回り込む動き方にした。ドリンクバーを動かして行き止まりもなくすと、60秒の止まりは57回から20回に、料理の待ちは試算に近づいた。"
tags: [サービスロボット導入シミュレーション, 配膳ロボット, Unity, ファミレス, シミュレーション, 経路, Claude Code, 段階B]
cover: "/media/LOG374_cover.jpg"
series: "導入シミュレーション"
---

配膳ロボットを2台入れたファミレスを Unity（建物や人を3Dで置いて動かせるソフト）の中で動かすと、2台が通路で向かい合ったまま止まる場面が何度も出ていました。原因は、ロボットに1本の線の上しか走らせない作りでした。そこで、実際の配膳ロボットと同じように、**ロボットに店の地図を渡し、行き先までの道を自分で引かせ、前がふさがったら回り込ませる形に変えると、60秒以上止まった回数は57回から20回に減りました。** 料理ができてから卓に届くまでの待ちも、2.90分から1.17分になり、サイトの試算の1.01分に近づきました。

この Unity の店は、サイトに置いている配膳ロボットの試算（ https://pixelstomotors.com/series8 ）と比べるために作っています。試算は、ロボットや人を点として動かす計算です。Unity は、同じ店の形・同じ人数・同じ客の数を、床・卓・人の体がある3Dの店で動かします。試算では見えない「通路で起きること」（すれ違い・立ち止まり・詰まり）を Unity で測り、試算の仮の値を本物に近づけるのが目的です。

道の引き方を変えたあと、3Dの画面を見てもう1つ分かったことがあります。右下の2人席の前の通路は、ドリンクバーと6人席の間が0.4mしかなく、ロボットにとって行き止まりでした。入った道を戻るしかないので、行きのロボットと帰りのロボットがそこで出会います。残った詰まりの7割がこの場所に集まっていました。ドリンクバーを上へ0.5m・左へ0.8m動かして抜け道を作ると、ここでの詰まりはなくなりました。

| やったこと | なぜ | 結果 |
|---|---|---|
| サイトから、店の床を2.5cmのマス目に分けた地図を書き出した | ロボットが自分で道を引くには、走れる範囲が要るため | 地図だけで、試算と同じ「狭い道でしか行けない卓」の分け方になる |
| Unity のロボットが、地図の上で道を自分で引き、ふさがったら3秒で回り込む形にした | 実際の配膳ロボットはそう動くため | 60秒の止まり 57回 → 59回（行き止まりに集中） |
| ドリンクバーを動かして、右下の行き止まりをなくした | 3Dの画面で、行き止まりの通路で2台が出会っていたため | 60秒の止まり 59回 → 20回、ロボットが運んだ皿 76% → 99% |
| 試算 v2.7（料理はロボット優先）を、店の形の直しと一緒に公開した | 店の形が変わったら、その店の試算も計算し直す決まりのため | ③⑦だけ計算し直し。差は小さい |
| 2つの店 × ロボット0・1・2台 × 3回の18回を回し直した | 1回だけでは偶然と区別できないため | ロボットがいる回は大きく良くなった。入店待ちのずれは残った |

一方で、残ったずれもあります。客が店に来てから席に着くまでの待ち（入店待ち）は、試算ではロボットを入れるほど短くなりますが、Unity ではロボットの台数で変わりませんでした。ロボット0台でもスタッフの手が足りていて、試算より人の仕事が軽く見えます。これはロボットではなく、人の動かし方の側のずれです。

下の図で、同じ条件で回した3回（線に縛っていた版・地図で道を引く版・ドリンクバーを動かした版）と、18回の条件ごとの数字を切り替えて見られます。

【図解】LOG374_jam_compare.html


## 作業ログ ― この記事で扱う範囲と、Unity・店の形・試算の版

前の記事（LOG370）で、ロボットどうしの詰まりの根本は、ロボットを決まった線の上しか走らせない作りだと分かりました。この記事は、その動き方を変えるところから、18回を回し直して基準のデータとして区切るところまでです。

| もの | 始め | 終わり |
|---|---|---|
| Unity のコード（Unity Version Control） | cs:141 | cs:143（v9 の本体が cs:142、NavMesh の種類の掃除が cs:143） |
| 店の形（p2m-site の public/sim/layouts/） | 82023936eb99 | 2fa10547a2b7（地図を足しただけ）→ 8a5b2f6a016a（ドリンクバーを動かした） |
| 試算（serving-robot.html） | v2.6 を公開・v2.7 は作業用の枝 | v2.7 を公開（③⑦だけ新しい店の形で計算し直し） |
| Unity で回した回 | v8b の確かめ（1004） | ③2台・種0 を2回（1304・1559）＋段階Bの18回（cs:143） |

効果の数字は、どれも回した記録から数えた値です。見込みのところは、見込みと書きます。


## 動き方を変える前に決めた3つ

動き方を変える依頼を Claude Code（Unity のコードを書く AI）に出す前に、決めておく必要があることが3つありました。どれも、実際のファミレスと配膳ロボットの動きに照らして決めました。

| 決めること | 選んだ案 | 理由 |
|---|---|---|
| ロボットどうしが出会ったとき、どちらが先に行くか | 料理を載せているロボットが先に行き、空で戻るロボットがよける。同じ状態なら1号機が先 | 料理を待っている客がいる方を優先する |
| 前がふさがってから、回り込む道を探し始めるまで | 3秒 | 人は数秒でよけることが多い。すぐ回り込むと、人がよけたあとも遠回りする |
| 通路のどこを走るか | 真ん中寄り（壁や卓のそばを道探しで重くする） | 実機は通路のぎりぎりを通らない、という見立てに合わせる |

前の版では、サイトが渡す道の線が、通れる範囲の中の最短の線でした。最短の線は通路の端に寄った1本になるので、行きと帰りが同じ線を走ります。真ん中寄りを走らせるのは、この重なりを避けるためでもあります。


## サイト：ロボットが道を引くための地図を書き出した（版 2fa10547a2b7）

店の形はサイトが正、という決まりがあります。Unity は、サイトが書き出したデータを読んで店を建てています。Unity の中で走れる範囲を勝手に作ると、サイトと Unity で「走れる所」の決め方が別々になります。そこで、サイトの試算が卓までの距離を測るときに使っている2.5cmのマス目を、そのまま地図として書き出しました。

| 項目 | 中身 |
|---|---|
| ファイル | public/sim/layouts/unity/{店の名前}_map.json（6店） |
| free_class | マスごとに 2＝ふだん走らせる幅（1.0m）の道が通れる／1＝通れる幅（0.8m）の道だけ通れる／0＝通れない |
| clear_mm | マスの中心から、いちばん近い障害物（壁・部屋・卓・会計の列）までの距離（mm） |
| 待機場所 | 床として入れる。止まっているロボットは、Unity の中で動くものとして扱う |
| 大きさ | ③は 960×640＝614,400マス。gzip で縮めて base64 にし、1店40〜100KB |

確かめに、地図だけを使って「受け渡し口からふだんの道で行ける卓」と「狭い道でしか行けない卓」を数え、試算の分け方と同じになることをテストに足しました（③は全42卓がふだんの道、⑦は24卓が狭い道）。確かめは135件すべて通りました。店の形・試算の数字は変わらず、変わったのは版の番号だけです。

1つだけ、確かめを書き直しました。ロボットの待機場所は、真ん中が壁から0.45mの位置にあります。ふだんの道（1.0m）の真ん中は壁から0.5m以上離れている必要があるので、待機場所は「狭い道（1）」のマスになります。実際の配膳ロボットも壁に寄せて止めるので、確かめを「狭い道で行ければよい」に直しました。


## v9：ロボットが地図の上で自分で道を引く（cs:142）

依頼文（claude_code_request_v9_map_planner.md）は、後ろに全文を載せます。中身は次のとおりです。

| | 前（cs:141） | v9（cs:142） |
|---|---|---|
| 道 | サイトの道の線の点を順にたどる（点と点の間は NavMesh） | サイトの地図の上で、行き先までの道をその都度自分で引く（NavMesh は使わない） |
| ふだんの道と狭い道 | 線で決まっている | 行き先まで2のマスだけで行けるなら2だけ、行けなければ1のマスを3倍重くして使う |
| 人やロボットに出会ったら | 止まって待つ。10秒ごとに同じ線を引き直す | 止まる → 3秒空かなければ、近く3mの人とロボットを通れないマスとして置いて回り込む道を引く |
| ロボットどうし | 番号の小さい台が先。横へどく点を探す | 料理を載せている方が先。低い方は相手の先の道（4m分）を重くして道を引き、引けなければ2.5m以内の離れたマスへ動いて待つ（最大20秒） |
| 線のために足した決まり（戻りの線・横へどく・角の箱） | 使う | 新しい動き方では使わない。pathMode=SiteRoutes で前の動き方に戻せる |
| 記録 | — | 引き直した回数・回り込んだ回数・よけた回数・延びた距離・道探しの時間などを足した |

変えなかったものも決めてあります。ロボットの速さ・加速・前のセンサー・卓での25秒・60秒で立ち往生・600秒で打ち切り・人のよけ方（ロボット＞スタッフ＞客）・料理はロボット優先、はそのままです。


### 人のいない店で道を引く確かめ（確かめ A）

Claude Code は、Unity がプロジェクトを開いたままでも、同じコードを Unity の外で組み立てて、受け渡し口から全卓への道を引きました。

| | ③ 効率重視・入口が角 | ⑦ ③の通路を95cmに |
|---|---|---|
| 狭い道を使った卓（サイト） | 0卓（0卓） | 24卓（24卓） |
| 道が引けない卓 | 0 / 42 | 0 / 42 |
| 道の長さ ÷ サイトの道の長さ（最小 / 平均 / 最大） | 0.959 / 1.019 / 1.057 | 0.959 / 1.037 / 1.227 |
| 1回の道探しの時間（平均 / 中央 / 最大） | 13.2 / 11.2 / 35.5 ms | 30.3 / 22.6 / 79.4 ms |

真ん中寄りにした分、道は平均で2〜4%延びます。サイトより短い卓が出るのは2つの理由です。T34・T35・T28 の1〜4cmは、道の端をマス目の真ん中にそろえた誤差です。T42 の22cmは、地図で待機場所を床にしたためで、サイトの道の線は待機場所を障害物として下へよけていました。


### Claude Code が決めて、報告で知らせてきたこと

依頼に決まりがなかった所を、Claude Code が決めて報告に書いてきました。どれも今の状態のまま回しています。

| 項目 | いまの決め方 | 決め直すかどうか |
|---|---|---|
| ロボットが、地図では通れないマスに立っているとき | 1.0m以内のいちばん近い通れるマスへ、出発点だけ寄せて道を引く | 前の版で60秒止まった51回のうち36回は、NavMesh には乗るが地図では通れない所だった。寄せた回数は記録に出る |
| 受け渡し口と客席の間の角の箱（止まった所が角の中かを数えるだけ） | 新しい動き方では数えない | 前の版の「止まった時間の3分の1がこの角」と並べられなくなる |
| 何もしていないロボットが待機場所へ動く処理 | 手を入れていない | 行き先を渡しているだけで、実際には動いていない疑い（あとで効いてくる） |


## 1回目（ドリンクバーを動かす前）：詰まりは減らず、右下の行き止まりに集まった

③・ロボット2台・種0 を回すと、最後まで回り切り、来た組も前と同じ64組でした。ロボットが運んだ皿は64%から76%に、料理の待ちは2.90分から2.12分になりました。ところが、60秒の止まりは57回から59回で、減っていません。場所を見ると、59回のうち43回が右下の2人席の前の通路（x 17〜20m・z 2m）に集まっていました。

回している画面を見て、すぐに理由が分かりました。

> 前より全然いいね。ただドリンクバーの位置を上に上げて、少し左寄せにして、右側のブースの回遊性を作らないと詰まるね

【画像】LOG374_01_deadend_jam.png 右下の2人席の前の通路で、白いロボット2台が向かい合っている。上は黒いドリンクバー、右は茶色の6人席

【動画】LOG374_v9_before_drinkbar.mp4 ドリンクバーを動かす前の店を真上から撮った動画（約51秒）。ロボットが地図の上で引いた道が細い線で出る。2台が右下の通路で何度も出会う

ドリンクバーの右端（x 20.8m）と6人席（x 21.2m）の間は0.4mで、ロボットは通れません。右下の8つの2人席へは、左から入って左へ戻るしかありませんでした。道の引き方を変えても、道が1本しかない所では、正面から出会うのは変わりません。

もう1つ、1往復のあいだに「よける」を156回くり返したロボットがいました。行き止まりで、よけては戻るをくり返していたと見ています。


## ドリンクバーを上へ0.5m・左へ0.8m：右下を一周できるようにした（版 8a5b2f6a016a）

ドリンクバーの位置を2案、図で比べました。青い所が、ロボットのふだんの道が通れる所です。

【画像】LOG374_02_drinkbar_options.png ③の右下の3案。上から、今（ドリンクバーと6人席の間が通れない）、A 上へ0.5m・左へ0.8m、B 上へ0.3m・左へ0.8m。A と B は、ドリンクバーのまわりを青い道が一周している

| | A 上へ0.5m・左へ0.8m（選んだ案） | B 上へ0.3m・左へ0.8m |
|---|---|---|
| 右の抜け道 | 1.2m（ふだんの道が通れる） | 1.2m |
| 下の通路 | 1.4m → 1.9m。卓の前にロボットが1台止まっていても、もう1台が狭い道として横を抜けられる | 1.7m。卓の前にロボットが止まると抜けられない |
| ドリンクバーの前に立つ人と待機場所の間 | 1.05m | 1.25m |
| ドリンクバーに並ぶ点 | 2つ → 1つ（2つめが待機場所に近すぎる） | 1つ |

卓までの距離が変わったのは、右下の6卓だけです。左寄りの T36〜T38 は0.18mずつ延び、右の T40・T41 は0.67mずつ近くなりました。卓の数・席の数・ふだんの道で行ける卓は変わりません。⑦は③の店をもとに作っているので、⑦も同じように変わります。

【画像】LOG374_03_site_plan_after.png 試算のページの③の平面図。ドリンクバーが上に寄り、右の6人席との間が空いている


### 試算 v2.7 を、店の形の直しと一緒に公開した

店の形の直しで卓までの距離が変わったら、その店の試算も計算し直して、店の形と一緒に公開する決まりです。公開中の試算は v2.6（料理は、ロボットとスタッフのうち先に空いた方が運ぶ）で、作業用の枝に v2.7（料理はまずロボットを待ち、2分たっても来なければスタッフも運ぶ）を置いたままでした。Unity は v2.7 を読んでいます。どちらを公開するかを聞き、v2.7 を公開すると決めました。

- ③⑦の3,066条件だけを、新しい店の形と v2.7 で計算し直した（クラウドの作業環境で2つ並べて19分）。ほかの店の13,359条件は v2.7 の結果のまま
- 計算し直した差は小さい：入店待ちの差の平均は ③−0.05分・⑦−0.06分、店が回らない条件は ③203→203・⑦202→203
- ページの前提の一覧に「配膳の分担：先に空いた方が運ぶ」が残っていたので、v2.7 の決まりに書き直した
- ⑦の名前が「③の通路を80cmに」のままだった（0.95mに広げる前の名前）ので、「95cmに」に直した
- 試算から数字を読んでいる導入前ガイドの LP も、v2.7 の数字に変わる。ビルドして、文と数字が食い違わないことを確かめた（数字はどれも試算から計算して出している）


## 2回目（ドリンクバーを動かした後）：60秒の止まりは20回、料理の待ちは試算に近づいた

1回目と同じ条件で、もう一度回しました。来た組は64組で同じです。

| ③・ロボット2台・種0 | v8b（線に縛る） | v9・ドリンクバーを動かす前 | v9・ドリンクバーを動かした後 | 試算 v2.7 |
|---|---|---|---|---|
| 60秒の止まり（うちロボットどうし） | 57（51） | 59（36） | 20（15） | — |
| 1往復で止まった秒（平均） | 109.9 | 83.4 | 33.4 | — |
| 往復 / 打ち切り（600秒） | 72 / 11 | 92 / 10 | 114 / 1 | — |
| ロボットが運んだ皿の割合 | 64.4% | 76.2% | 99.4% | 99.2% |
| 料理の待ち | 2.90分 | 2.12分 | 1.17分 | 1.01分 |
| 入店待ち | 1.67分 | 1.61分 | 1.47分 | 1.08分 |
| 回り込んだ回数 / よけた回数（1往復の平均） | — | 1.04 / 4.54 | 0.65 / 1.41 | — |
| 1往復でよけた最大の回数 | — | 156 | 21 | — |
| 逃がし弁（あきらめた / 移した / 卓を空けた） | 61 / 54 / 19 | 71 / 78 / 5 | 31 / 59 / 2 | — |

- ねらい：ロボットに地図を渡して道を自分で引かせ、右下の行き止まりをなくして、ロボットどうしが正面から出会う場所を減らす
- 変わったこと：道は地図の上でその都度引き、ふさがったら3秒で回り込む。ドリンクバーを動かし、右下を一周できるようにした
- 見込める効果：測った値で、60秒の止まりは57回から20回、1往復で止まった秒は110秒から33秒、打ち切りは11回から1回。ロボットが運んだ割合と料理の待ちは、試算とほぼ同じ大きさになった

止まった回数そのものは490回から2,090回に増えました。1回ずつは短く止まって、回り込んで動き出しています。長く固まる止まり方から、短く止まって回り込む止まり方に変わりました。


### 残った20回の60秒の止まり

| 場所 | 回数 | 中身 |
|---|---|---|
| 受け渡し口の左下（x 14.3m・z 5.3m） | 10 | 店が終わりに近い152〜160分に、1号機が2号機に7回続けて止められた。何もしていないロボットが待機場所へ動かず、受け渡し口の近くに立っていた疑い（Claude Code が報告で書いていた「待機場所へ実際には動いていない」と合う） |
| 右の抜け道の入口（x 20.0m・z 2.7m） | 4 | スタッフ1人が4分以上立ち止まり、2号機が通れなかった |
| 通路の入口の手前（x 11m・z 4m） | 3 | ロボットどうし |
| ほか | 3 | ロボットどうし2・スタッフ1 |

逃がし弁（動けなくなった人やロボットを、決めた時間で先に進める仕組み）は92回のうち70回が、ドリンクバーで並べずに移された客、またはあきらめた客でした。ドリンクバーを動かす前も72回あったので、人の動きの側の問題です。


### ③で、回り込みのときに狭い所を通った往復が114回中8回あった

人のいない店では、③の卓はどれも狭い道を使いません。狭い道を通ったのは、人やもう1台でふだんの道がふさがったときに、0.8mのすき間を抜けて回り込んだ場面です。Claude Code から、狭いマスをもっと重くするか、ふさがったら待つかを聞かれました。

このままにしました。実機の BellaBot も0.8mあれば通れるので、ふさがったときにすき間を抜けるのは現実の動きに近いと考えます。サイトの「狭い道を使う卓」は店の形だけで決まる分け方で、混雑の中で狭い所を抜けるのは、Unity が測る「通路で起きること」の側に入ります。サイトの分け方に数字を合わせるために値を動かすと、帳尻合わせになります。比べるときは「店の形で狭い道を使う」と「回り込みで狭い所を通った」を分けて数えます。


## 段階Bの18回を回し直した（cs:143）

2つの店（③・⑦）× ロボット0・1・2台 × 乱数の種3つの18回を、Stage B の Run All でまとめて回しました（16時33分ごろ〜23時13分）。18回とも全員が店を出るまで回り切りました。来る客は種ごとに、ロボットの台数に関係なくそろっています（種0は64組、種1は83組、種2は102組。平均83組で、試算の約84組とほぼ同じ）。

前の18回（cs:140）とは来る客の作り方が違うので、比べるときは向きと大きさで見ます。

| 3回の平均 | 前の18回（cs:140） | 今回（cs:143） | 試算 v2.7 |
|---|---|---|---|
| ③2台・料理の待ち | 4.46分 | 1.51分 | 1.01分 |
| ⑦2台・料理の待ち | 3.53分 | 1.38分 | 1.01分 |
| ③2台・ロボットが運んだ割合 | 67.8% | 89.9% | 99.2% |
| ⑦2台・ロボットが運んだ割合 | 76.6% | 94.7% | 99.2% |
| ③2台・打ち切り（3回の合計） | 24 | 7 | — |
| ⑦2台・打ち切り（3回の合計） | 21 | 6 | — |
| ③2台・ロボットどうしの60秒の止まり（3回の合計） | 94 | 61 | — |
| ⑦2台・ロボットどうしの60秒の止まり（3回の合計） | 111 | 35 | — |
| ③1台・料理の待ち | 3.61分 | 2.41分 | 2.03分 |
| ⑦1台・料理の待ち | 3.74分 | 2.53分 | 2.16分 |
| ③1台・ロボットが運んだ割合 | 63.7% | 72.3% | 72.8% |
| ⑦1台・ロボットが運んだ割合 | 62.2% | 69.0% | 70.8% |

⑦（通路を0.95mに詰めた店）は、新しい動き方で回すのはこれが初めてでした。前の18回では⑦2台の入店待ちが9.84分まで延びていましたが、今回は3.81分です。


### 残ったずれ：入店待ちがロボットの台数で変わらない

| 入店待ち（3回の平均） | ロボット0台 | 1台 | 2台 |
|---|---|---|---|
| ③ Unity | 3.48分 | 3.53分 | 3.66分 |
| ③ 試算 | 3.99分 | 1.82分 | 1.08分 |
| ⑦ Unity | 3.91分 | 3.97分 | 3.81分 |
| ⑦ 試算 | 4.78分 | 2.14分 | 1.14分 |

**入店待ちは、試算ではロボットを入れるほど短くなるのに、Unity ではロボットの台数で変わりませんでした。** 中身を分けると、2つのことが重なっています。

1つめは、種2（102組）の回です。どの台数でも入店待ちが7〜8.5分になり、3回の平均はこの1回で決まります。種0・1では1.4〜2.0分です。3回では、ロボットの効果と偶然の差が見分けられません。

2つめは、ロボット0台でもスタッフの手が足りていることです。席は空いているのに片付けや案内が追いつかずに待った時間は、種0・1で0.3〜0.7分しかありません。試算では、ロボット0台だとスタッフが足りず、③で3.36分・⑦で3.95分待ちます。Unity のスタッフの仕事が試算より軽い疑いがあります。疑っているのは、片付けが試算より短いことと、逃がし弁の「卓を空けた」が片付けを飛ばしていること（ロボット0台で ③61回・⑦92回）です。前のずれの一覧の5番（ロボットなしで卓が空くまでが短い）と同じ根です。

もう1つ、ドリンクバーの逃がし弁が増えました。⑦のロボット0台では、移された・あきらめた客が3回の合計で69人から148人に増えています。ドリンクバーを動かしたときに、並ぶ点が2つから1つに減ったのが疑いです。店の形の決まりで、並ぶ点を横に並べれば戻せる見込みです。


## 基準データ v1 と、夜のあいだも回すための改良

別の会話で、研究室が寝ている間もシミュレーションを回し続けるには何が要るかを話し、4つの改良を、区切り → 2 → 4 → 1 → 3 の順で進めると決めていました。

| # | 改良 | 中身 |
|---|---|---|
| 1 | ケースを部品の組み合わせで書ける型 | 店の形 × ロボット機種 × 運用の決まり × 来客の波を別々の部品にする |
| 2 | 夜の結果を自動で振り分ける判定 | 立ち往生・向かい合い・サイトとのずれを自動で見つけ、「正常」「Unity の作りの問題」「サイト側を見直す候補」に分ける |
| 3 | 次に回す条件を選ぶ仕組み | 回る・回らないが切り替わる境目の近くを重点的に回す。境目を探すのは速い試算（SimPy）、Unity は見つかった条件だけ |
| 4 | 画面なしで回す実行環境 | Unity を画面を出さずに起動し、条件と乱数の種を外から渡す |

その区切りを、この18回にしました。Unity の版 cs:143・店の形 8a5b2f6a016a・試算 v2.7 の組を「基準データ v1」とし、KIOXIA の service_sim/unity/stageB_cs143/ に、版・出力の場所・判定に使う事実・分かっている作りの問題をまとめた README を置きました。改良を入れたあとの回は、この18回と比べます。

この18回から、自動の判定を決めるときの材料が出ています。

- 判定に使うのは、ばらつきの小さい料理の待ちとロボットが運んだ割合。どの条件も試算と同じ向きで、大きさの比は2倍未満（いちばん離れているのは⑦0台の料理の待ちで1.72倍）
- 入店待ちは、種3つでは判断に使えない。使うなら種を増やす
- ロボット0台の回が試算とずれていたら、ロボットの回は比べずに「人の動かし方の問題」として分ける
- 改良の最初に直す候補：待機場所へ動かないロボット、片付けを飛ばす「卓を空けた」、台帳の pause_rate・stuck_rate が空、ドリンクバーの並ぶ点が1つ

夜に回すときの実際も分かりました。1回の実時間は、店の中の3時間ほどを10.6〜20.9分で回します。18回の通しは、途中でふたを閉じた分を含めて約6時間40分でした。ふたを閉じると Mac はスリープし、回している1回はそこで止まります。開けると続きから動き、店の中の時間は実際の時間と別に進むので、結果は変わりません。1回の上限は40分で、3回目はふたを閉じた分を含めて32分で終わり、台帳にも書かれました。終わった回は1回ごとに台帳に書かれ、もう一度押すと同じ版で終わった回は飛ばします。回している間はスリープを止める仕組みが入っていますが、ふたを閉じたときのスリープまでは止められません。画面なしで回す改良（4）でも、この点は残ります。


## データとコードの置き場所

| もの | 場所 |
|---|---|
| Unity の出力（③2台・種0 の2回） | KIOXIA r2r2r-data/service_sim/unity/20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1304・…_1559 |
| 段階Bの18回の出力 | KIOXIA r2r2r-data/service_sim/unity/20261024_stageB_cs143_{fam_p1・fam_p5}_r{0・1・2}_s{0・1・2} |
| 台帳 | KIOXIA r2r2r-data/service_sim/unity/unity_ledger_v1.csv（unity_cs が cs:143 の18行） |
| 基準データ v1 の説明と、集計のコードと出力 | KIOXIA r2r2r-data/service_sim/unity/stageB_cs143/（README.md・b1〜b4） |
| 依頼文・報告・確かめ A の出力・ずれの一覧・設計書 | iCloud サービスロボット導入シミュレーション/unity/（claude_code_request_v9_map_planner.md・claude_code_reports/20261024_1155_v9_map_planner.txt・screens/20261024_1150_v9_*・unity_stageC_gaps_v1.md・unity_design_v0.md） |
| Unity が読んだ店の形と試算の写し | iCloud サービスロボット導入シミュレーション/unity/site_copy_2fa10547a2b7/・site_copy_8a5b2f6a016a/ |
| Unity のコード | /Users/shimazueiichi/Robotics_UX/Robotics_UX_New/Assets/ServiceSim/（Unity Version Control cs:143） |
| 店の形と地図 | GitHub PixelstoMotors/p2m-site の tools/sim_layouts/・public/sim/layouts/（commit 1ee9d8e・a33aefd） |
| 試算 v2.7 の計算し直し | iCloud サービスロボット導入シミュレーション/simulation/v2_7/（run_p1p5.py・merge_v27.py・rows_v27_p1p5.json・compare_v27_drinkbar.txt・README.md） |

Unity のコードは、Unity Version Control の差分を取り出せる環境が手元にないので、この記事で直したファイルの cs:143 の全文を載せます。新しく作った4つのファイルは、Claude Code の報告の後ろにも全文が入っています。回した記録のうち、人どうしの詰まりの記録（people_jams.csv）と、1回ずつの CSV は KIOXIA の同じフォルダにあります。


## Claude Code への依頼文

長い依頼文はファイルにして Mac に置き、Claude Code には「unity/claude_code_request_v9_map_planner.md を読んで、書いてある依頼を進めてください」とだけ送りました。

### 依頼 v9：claude_code_request_v9_map_planner.md

````markdown
# 依頼 v9：ロボットが地図の上で自分で道を引く（2026-10-24）

いまのロボットは、サイトの道順（routes）の点を順にたどっている。道順は通れる範囲の中の最短の線なので、通路の端に寄った1本になり、行きと帰りが同じ線を走る。広い所でも2台が正面から出会い、回り込めずに60秒止まる（v8b の確かめの③2台・種0：60秒の止まり57回のうち51回がロボットどうし）。戻りの線・横へどく・角の決まりは、これを後から補うために足したもの。

現実の配膳ロボットは、動ける範囲を地図で持ち、道はその都度自分で引き、人や障害物にはセンサーで止まる・回り込む・引き直す。v9 でこの形に変える。サイトは地図を正として渡し（版 2fa10547a2b7 で公開済み）、道は Unity のロボットが地図の上で引く。NavMesh はロボットの道探しには使わない。

## 変えないもの

- ロボットの速さ・加速・減速・向きを変える速さ・前のセンサー・動き出すまでの1秒（RobotSpec の値）
- 卓での25秒、60秒で立ち往生してスタッフを呼ぶこと、スタッフが動かす流れ、1往復の打ち切り600秒と v8b の置き直し
- 止まる位置・受け渡し口の点・待機場所（サイトの JSON のまま）
- 人のよけ方（ロボット＞スタッフ＞客の順。PersonRobotYield）。人がロボットの先の道を見る `PathAhead`・`CurrentLegSegment` は、新しい道を返すようにして残す
- 料理はロボット優先・2分で人が運ぶ、仕事の順番（ServiceOps）
- 細い通路の入口で1台ずつ入る（oneRobotPerAisle・aisleYielding）
- 人に自分をよけてもらうための NavMeshAgent（自動でよける機能は切ったまま）
- v8・v8b で直した所

## 0. サイトの新しい版を写す

ネットから取らず、Mac に置いたここから写す。

```
~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/site_copy_2fa10547a2b7/
  summary.json
  serving-robot-params.json      （試算 v2.7。版の欄だけ 2fa10547a2b7 にした）
  unity/p1_efficient_corner.json  unity/p1_efficient_corner_map.json
  unity/p5_crammed.json           unity/p5_crammed_map.json   （ほかの店の分もあるが、今は③⑦だけ）
```

- Store/Layouts に summary.json・p1・p5 の JSON と、新しい `_map.json` の2つ。Experiment に serving-robot-params.json
- 店の形の中身は前の版（82023936eb99）と同じ。変わったのは版の番号と、規則の説明文だけ
- 4つの版の番号（summary の generation、店の JSON の meta.generation、地図の meta.generation、params の layout_generation）が、すべて 2fa10547a2b7 でそろっていることを確かめる。そろわなければ回さない（今の確かめに地図を足す）

## 1. 地図を読む

JsonStoreLayout に地図の欄（TextAsset `mapJson`）を足し、店の JSON と一緒に読む。読み方は地図の JSON の `layout`・`encoding` に書いてある。

- `free_class`・`clear_mm` は base64 → gzip を解く（GZipStream）。free_class は1マス1バイト、clear_mm は1マス2バイト（小さい桁が先）
- マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res。Unity では x→X、y→Z。配列は iy=0 の行から、1行は ix=0 から nx 個
- free_class：2＝ふだんの道（1.0m）が通れる、1＝通れる幅（0.8m）の道だけ、0＝通れない。clear_mm：いちばん近い障害物までの距離（mm）
- 読んだら確かめる：2・1・0 の数が JSON の `counts` と同じ。受け渡し口の点・全卓の止まる位置が1以上のマス。待機場所の真ん中も1以上（壁ぎわなので1になる）

地図は店の形を変えない。Unity で地図を塗り直したり、通れる所を足したりしない。

## 2. 道の引き方（新しいファイル Robot/GridPlanner.cs。ロボットだけ）

**ふだんの道か狭い道か（試算と同じ規則）**
- 行き先まで2のマスだけで行けるなら、2のマスだけで道を引く
- 行けなければ、1のマスも使って引く。ただし1のマスは重くして（`narrowCellCost`、目安3倍）、狭い所は必要な所だけ通る
- 道に1のマスが入った区間を「狭い道を使った」とする（今の LegWasNarrow・UsedNarrowAisle に入れる）

**通路の真ん中寄りを走る（エイイチの決定）**
- 1歩の重さに、障害物に近いほど重くなる分を足す：`centerWeight × max(0, preferClearM − clear) / preferClearM`。目安は preferClearM 0.6m（1.2mの通路なら真ん中）、centerWeight 2
- まっすぐにつなぐとき（曲がり角の点だけにするとき）は、もとの道で通っていた所より障害物に近づかない所だけで近道する。端に寄った線に戻さないため
- 引いた道の長さは、サイトの距離の決め方を変えない。延びた分は Unity が測る値として記録する

**道探しの決まり**
- 8方向。斜めは両隣が通れるときだけ（サイトと同じ）
- 同じ条件なら毎回同じ道になるようにする（乱数を使わない。同じ重さのときの選び方を決めておく）。のちのち学習に使うため
- 引き直しは1台につき0.5秒に1回まで。1回の時間（ms）を測り、往復ごとの最大を記録する

**前の動き方は切り替えで残す**
- RobotSpec に `pathMode` を足す：`MapPlanner`（新しい・既定）／`SiteRoutes`（今まで）
- 戻りの線（useReturnLine）・横へどく（yieldOrder の横へどく点）・角の箱（cornerMin/Max）は、SiteRoutes のときだけ働く。MapPlanner では使わない
- コードは消さない。前の版と同じ条件で比べられるようにする

## 3. 人や物で前がふさがったとき

- いまのとおり前のセンサーで止まる。空いたら1秒で動き出す
- **止まって3秒たっても前が空かなければ（`detourAfterSec` 3秒。エイイチの決定）、回り込む道を探す**
  - いまロボットの近く（目安3m以内）にいる人・ほかのロボットを、その1回の道探しの間だけ通れないマスとして置く（人は半径＋ロボットの半径＋0.1m、ロボットは2台の半径の和＋0.1m）
  - 道が引ければその道で進む。延びた距離が大きすぎる道（目安：もとの残りの道の2倍＋5m より長い）は取らずに待つ
  - 引けなければ待ち、3秒ごとに探し直す
- 止まる位置そのものに人が立っているときは、回り込まずに待つ
- MapPlanner では、今の「止まって10秒ごとに引き直す」と、途中までの道から通路の入口へ出る（escape）は使わない
- 60秒動けなければ立ち往生（今と同じ）

## 4. ロボットどうし（エイイチの決定：料理を載せている方が先）

**優先の順番**
1. 料理を載せているロボット（卓へ向かっている。卓から次の卓へ向かう間も含む）
2. 空で戻るロボット（受け渡し口・待機場所へ向かっている、待機場所へ動いている）
3. 同じ状態なら1号機が先

**優先が低いロボットのよけ方**
- 道を引くとき、優先が高いロボットの先の道（PathAhead で目安4m分）から、2台の半径の和＋0.2m以内のマスを重くし、相手の体は通れないマスとして置く。相手の道とかぶらない道があれば、そちらを選ぶ
- 引き直すのは、優先が高いロボットが3m以内に来て、先の道がかぶるとき
- かぶらない道が引けないとき：近く（2.5m以内）で、相手の先の道から2台の半径の和＋0.1m以上離れ、自分が入れるマス（1以上）を探し、そこへ動いて相手が通り過ぎるまで待つ。待つのは最大20秒（今の robotYieldMaxSec）。過ぎたら自分の道で進む
- 優先が高いロボットは、相手を気にせず道を引く。ただし前のセンサーで止まり、3秒空かなければ3の回り込みをする（どちらも止まり続けることがないように）
- 2台が向かい合って10秒止まったら、優先が低い方が上のよける場所へ動く

細い通路の入口で1台ずつ入る決まりは今のまま（1.2mの通路に2台は並べない）。

## 5. 記録

trips.csv の後ろに足す（今の列は変えない）：
- `trip_end_s`：往復が実際に終わった店の中の時刻（卓での25秒を含む。v8 の確かめで決めた分。total_s はそのまま）
- `path_mode`：MapPlanner / SiteRoutes
- `replans`：道を引き直した回数（回り込み・よけを含む）
- `detours`：3の回り込みで道を変えた回数
- `robot_yields`：4でよけた回数
- `site_route_m`：その往復でサイトの道順をたどったときの長さ（routes の length の和）
- `detour_extra_m`：走った距離 − site_route_m
- `narrow_used`：狭い道（1のマス）を通ったか
- `plan_ms_max`：その往復で1回の道探しにかかった最大の時間

stops.csv：止まりの解け方に `Detoured`（回り込んで動き出した）と `YieldedToRobot`（よける場所へ動いた）を足す。止まりの原因の分け方は今のまま。
run_info.json：path_mode、地図の版の番号、narrowCellCost・centerWeight・preferClearM・detourAfterSec の値。

## 6. 見せる（測る回でも出してよい。切れるように）

- 各ロボットのいま引いている道を、床の上に細い線で出す（料理を載せているときと空のときで色を変える）
- キッチンの屋根のモニターに、ロボットのいまの様子（走る・止まる・回り込む・よける）を出す

## 7. 確かめ

**A. 人のいない店で（Claude Code が回す）**
③と⑦で、受け渡し口から全卓への道を引く：
- 狭い道を使った卓が、サイトの routes の use（③は0卓、⑦は24卓）と同じ
- 道の長さとサイトの routes の length の比（最小・平均・最大）。真ん中寄りにした分だけ延びるはず
- 道探しの時間（平均・最大）
- 道が引けない卓がない。待機場所へも引ける

**B. 回す（エイイチが押す）**
ServiceSim / Site Run：③ ロボット2台・種0 を1回。
比べる相手は v8b の同じ回（`20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1004`）：
- 60秒の止まりの数と、そのうちロボットどうしの数（v8b：57回・51回）
- 逃がし弁（v8b：あきらめた61・移した54・卓を空けた19）
- 1往復で止まった秒（平均）、ロボットが運んだ皿の割合
- detours・robot_yields・detour_extra_m の平均
- 最後まで回り切る（全員が店を出る）。来た組は v8b と同じ64組

## 報告

- 直したファイルと、変えた所の説明（新しいファイルは全文）
- 確かめ A・B の結果（数字つき）と、出力フォルダ名
- 決めきれなかった値や、この依頼どおりにすると困る所があれば、直す前に書いて止める
- B が最後まで回り切ったら Check-in して番号を報告

段階Bの18回の回し直しは、私（Claude）が B の結果を見てから頼む。
````



## Claude Code の報告

報告は3つです。1つめはファイルに書かれた報告で、新しく作った4つのファイルの全文も入っています。2つめと3つめは、チャットに貼られた報告です。

### 11:55 v9 を入れた報告（claude_code_reports/20261024_1155_v9_map_planner.txt）

```text
# 依頼 v9 の報告：ロボットが地図の上で自分で道を引く（2026-10-24）

書いた日時: 2026-10-24 11:55
依頼: unity/claude_code_request_v9_map_planner.md

やったこと: 0〜6 章を入れて、7 章 A（人のいない店で道を引く確かめ）を回した。
7 章 B（③ ロボット2台・種0 を1回）はエイイチが押す。
**先に「決めた値」と「気になる所」を書きます（6 章のあと）。読んでから B を押してください。**


====================================================================
1. 直したファイル
====================================================================

## 新しく作ったファイル（全文はこの報告のいちばん後ろ）

  Assets/ServiceSim/Robot/GridPlanner.cs
      地図（RobotMap）と、その上で道を引く道探し（GridPlanner）。
  Assets/ServiceSim/Robot/MapPlanCheck.cs
      確かめ A の中身。Unity のメニューからも、Unity の外からも同じものを呼ぶ。
  Assets/ServiceSim/Robot/RobotPathLine.cs
      いま引いている道を床に細い線で出す（6 章）。見た目だけ。
  Assets/ServiceSim/Editor/ServiceSimMapPlanner.cs
      メニュー「ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）」。

## 直したファイル

  Store/JsonStoreLayout.cs
      ・地図の欄 mapJson（TextAsset）を足し、店の JSON と一緒に読む。
      ・読んだあとの確かめ（1 章）：2・1・0 の数が JSON の counts と同じ／
        版の番号が店の形と同じ／1 マスの大きさが meta.settings.res と同じ／
        受け渡し口の点・全卓の止まる位置・待機場所が 1 以上のマス。
        どれか合わなければ Compose が止まる（店を建てない）。前と同じ決まり。
      ・Build Store の報告に地図の行が出る。
      ・MapGeneration を外から読めるようにした（run_info.json に出すため）。

  Store/StorePlan.cs / Store/StoreBuilder.cs
      読んだ地図を plan.robotMap に持たせ、StoreBuilder.RobotMap で読めるようにした。

  Robot/RobotSpec.cs
      ・enum PathMode { MapPlanner, SiteRoutes } を足し、pathMode を足した（既定 MapPlanner）。
      ・2〜4 章の数字を足した（narrowCellCost 3 / centerWeight 2 / preferClearM 0.6 /
        replanMinIntervalSec 0.5 / detourAfterSec 3 / robotPathAheadM 4 など）。
      ・UseMapPlanner と PlannerSettings() を足した。
      ・走り・加速・減速・向きを変える速さ・前のセンサー・動き出すまでの1秒・
        卓での25秒・60秒・600秒は**何も変えていない**。

  Robot/ServingRobotController.cs（いちばん大きく直した）
      ・MapPlanner のときは SetDestination が NavMesh ではなく GridPlanner を呼ぶ。
        行き先は卓の止まる位置（または受け渡し口）そのもので、サイトの routes の点は
        たどらない。NavMesh はロボットの道探しには使わない。
      ・PathAhead・CurrentLegSegment は、サイトの道順でも地図で引いた道でも
        「これから進む道」を返すようにした（人のよけ方は変えていない）。
        まとめて見る WayCount / WayIndex / WayPoint を足した。
      ・3 章：前がふさがって 3 秒たったら回り込む（UpdateDetour / TryDetour）。
        近く 3 m の人・ロボットをその 1 回だけ通れないマスとして置く
        （人は 人の半径＋ロボットの半径＋0.1 m、ロボットは 2 台の半径の和＋0.1 m）。
        延びた距離が もとの残り×2＋5 m より長い道は取らずに待つ。
        引けなければ 3 秒ごとに探し直す。止まる位置そのものに人がいるときは回り込まない。
      ・4 章：YieldRank（料理を載せている 0 ＞ 空で戻る 1、同じなら 1 号機が先）と
        UpdateRobotYieldMap。優先が低い側は、相手が 3 m 以内に来て先の道（4 m 分）が
        かぶったら、相手の道を重くして引き直す。かぶらない道が引けないときは、
        2.5 m 以内で相手の道から 0.6 m 以上離れた通れるマスへ動いて待つ（最大 20 秒）。
        向かい合って 10 秒止まったら、引き直しを待たずによける場所へ動く。
      ・引き直しは 1 台につき 0.5 秒に 1 回まで（replanMinIntervalSec）。
      ・MapPlanner では「止まって10秒ごとに引き直す」と escape を使わない。
      ・SiteRoutes のときだけ 戻りの線（useReturnLine）・横へどく点（yieldOrder）が働く。
      ・記録：Replans / Detours / RobotYields / PlanMsMax / SiteRouteM / TripEndS /
        MapSnapStarts / PlanFails を足した。
      ・6 章：UpdatePathLine で床の線を出す。

  Experiment/TripLogger.cs
      ・trips.csv の**後ろに**足した（今の列は 1 つも動かしていない）:
          trip_end_s, path_mode, replans, detours, robot_yields,
          site_route_m, detour_extra_m, narrow_used, plan_ms_max
      ・stops.csv の resolved に「回り込んで動き出した」「よける場所へ動いた」を足した。
        止まりの原因の分け方は今のまま。
      ・run_info.json に path_mode / map_generation / narrow_cell_cost / center_weight /
        prefer_clear_m / detour_after_sec / plan_ms_max_per_trip / map_snap_starts /
        plan_fails を足した。

  Experiment/ExperimentRunner.cs
      ・上の列に値を入れる。
      ・角の箱は MapPlanner のときは入れない（下の「気になる所 (4)」）。

  Store/KitchenMonitor.cs
      6 章：ロボットのいまの様子に 走る／止まる／回り込む／よける を出す。

  Service/ServiceOps.cs
      1 行だけ：yields.csv の in_corner を MapPlanner では立てない（角の箱と同じ理由）。

## 写したファイル（0 章）

  Store/Layouts/summary.json                   ← site_copy_2fa10547a2b7/summary.json
  Store/Layouts/p1_efficient_corner.json       ← 同 unity/
  Store/Layouts/p5_crammed.json                ← 同 unity/
  Store/Layouts/p1_efficient_corner_map.json   ← 同 unity/（新しい）
  Store/Layouts/p5_crammed_map.json            ← 同 unity/（新しい）
  Experiment/serving-robot-params.json         ← site_copy_2fa10547a2b7/

  新しい 2 本に .meta を作り、StoreLayout_p1_efficient_corner.asset と
  StoreLayout_p5_crammed.asset の mapJson に結び、fetchedAt を
  「2026-10-24 11:00:00 JST（版 2fa10547a2b7）」に直した。
  RobotSpec.asset に v9 の値を書き足した。

  4 つの版の番号はぜんぶ 2fa10547a2b7 でそろっている（写したファイルを読んで確かめた）:
      params の layout_generation  2fa10547a2b7
      p1: 店 2fa10547a2b7 / summary 2fa10547a2b7 / 地図 2fa10547a2b7（res 0.025 / 0.025）
      p5: 店 2fa10547a2b7 / summary 2fa10547a2b7 / 地図 2fa10547a2b7（res 0.025 / 0.025）
  回すときの確かめも 3 か所でかかる:
      店 ↔ summary       JsonStoreLayout.CheckGeneration（前からある）
      店 ↔ 地図           JsonStoreLayout.ReadMap（今回足した）
      店 ↔ 試算の値       ExperimentRunner の simParams.Load（前からある）
  そろわなければ建てない・回さない。


====================================================================
2. 確かめ A の結果（回した）
====================================================================

回した所: Unity の外（mono）。
Unity Editor がプロジェクトを押さえていて 2 つめの Unity を立てられないので、
Assets の MapPlanCheck.cs / GridPlanner.cs / SimpleJson.cs を**そのまま**
Unity 同梱の mono で組み立てて回した。Unity のメニュー
「ServiceSim/Map Planner/③⑦ 確かめ A」が呼ぶのと**同じ MapPlanCheck.Run** なので、
Unity で押しても同じ数字が出るはずです（押して確かめてください）。

## ③ p1_efficient_corner

  地図: 版 2fa10547a2b7 / 960 × 640 = 614400 マス / 1 マス 0.025 m
  マスの数: 2（ふだんの道）148223 / 1（通れる幅だけ）30397 / 0（通れない）435780
        → 地図の JSON の counts と **一致**
  受け渡し口の前 (15.3, 5.95) → マスの種類 2

  狭い道を使った卓     引いた道 0 卓 / サイトの routes の use 0 卓   → **一致**
  道が引けない卓       0 / 42
  長さの比（引いた道 ÷ サイトの length）  最小 0.959 / 平均 1.019 / 最大 1.057
  道探しの時間         平均 13.2 ms / 中央 11.2 ms / 最大 35.5 ms
  出発点・行き先を寄せた卓  0 / 42
  待機場所1 (16.9, 6.05) マスの種類 1 → 引けた（1.605 m・0.5 ms・狭い道を使った）
  待機場所2 (17.7, 6.05) マスの種類 1 → 引けた（2.403 m・0.9 ms・狭い道を使った）
  待機場所3 (18.5, 6.05) マスの種類 1 → 引けた（3.202 m・1.1 ms・狭い道を使った）
  → 確かめ A は通った

## ⑦ p5_crammed

  地図: 版 2fa10547a2b7 / 960 × 640 = 614400 マス（counts と一致）

  狭い道を使った卓     引いた道 24 卓 / サイトの routes の use 24 卓  → **一致**
  道が引けない卓       0 / 42
  長さの比           最小 0.959 / 平均 1.037 / 最大 1.227
  道探しの時間         平均 30.3 ms / 中央 22.6 ms / 最大 79.4 ms
  出発点・行き先を寄せた卓  0 / 42
  待機場所1〜3 ぜんぶ 引けた（1.6 / 2.4 / 3.2 m・0.5〜1.1 ms・狭い道を使った）
  → 確かめ A は通った

長さの比が 1 を少し超えるのは、真ん中寄りにした分です（依頼どおり）。
最小が 0.959（サイトより少し短い）になるのは、サイトの折れ線を
マス目の 8 方向でたどり直すと斜めが少しまっすぐになるからです。

## 道探しそのものの確かめ（確かめ A では通らない道を見た）

  ○ 1. 同じ条件なら毎回同じ道      点 9 個・10.825 m を 2 回引いて同じ（乱数なし）
  ○ 2. 人を通れないマスにすると道が変わる   10.825 m → 11.697 m
  ○ 3. 重いマスを差し込んでも引ける        10.825 m → 16.145 m（別の道を選んだ）
  ○ 4. 行き先をふさぐと引けない           「つながっていない」
  ○ 5. 出発点が 0 のマスでも寄せて引ける   (10.0, 4.5) マスの種類 0 → 0.088 m 寄せて引けた
  ○ 6. 曲がり角の点がすべて通れるマスの上   はみ出し 0 点
  ○ 7. 曲がり角だけにしても長さが暴れない   マスをたどった 11.122 m → 縮めた 10.825 m

## 出力フォルダ

  unity/screens/
    20261024_1150_v9_checkA_map_planner.txt          確かめ A の全文
    20261024_1150_v9_planner_unit_checks.txt         上の 7 つの確かめ
    20261024_1150_v9_map_plan_p1_efficient_corner.csv  卓ごと（use・長さ・比・角の数・ms）
    20261024_1150_v9_map_plan_p5_crammed.csv


====================================================================
3. 決めきれなかった値・この依頼どおりにすると困る所
====================================================================
（依頼の「報告」のとおり、ここを読んでから B を押してください。
 (2) と (4) はエイイチに決めてほしいところです）

## (1) 地図の free_class は「中心を置ける所」で、通路の幅のぶんもう削ってある

  free_class は体の半径（0.25 m）ではなく、**道の幅の半分**（1.0 m の道なら 0.5 m、
  0.8 m の道なら 0.4 m）で削ってあります。地図を横断して測った実際の値:

    ③ 通路 A01（幅 1.2 m）  中心を置ける帯 0.45 m（うち 2 のマスは 0.25 m）
    ⑦ 通路 A01（幅 0.95 m） 中心を置ける帯 0.20 m（ぜんぶ 1 のマス）

  2 台がすれ違うには中心どうし 0.5 m 要るので、**通路の中では 2 台は並べません**。
  体だけで見れば 1.2 m の通路には 0.75 m の帯があって並べるのですが、
  依頼の「地図を塗り直さない・通れる所を足さない」に従って free_class だけを使いました。

  その結果、4 章の「相手の道とかぶらない道を選ぶ」「0.6 m 横へどく」は
  **細い通路の中では成り立ちません**（どいても 0.225 m / 0.1 m しか離れられない）。
  通路の中で正面から出会ったら、片方が通路の外へ出るか、20 秒で待つのをやめて進むか、
  60 秒で立ち往生になります。

  ただし、v8b の比べる回（20261024_1004）の stops.csv を見ると、
  **60 秒の止まり 51 回（ロボットどうし）は全部が細い通路の外**でした
  （aisle の欄が空・in_narrow が 0）。止まった所の内訳:

    (10.0, 4.5) 11回  (10.5, 4.5) 9回   ← 通路 A03 の入口の手前
    (17.0, 2.5)  6回  (19.0, 2.5) 6回   ← レジ・入口のあたり
    (13.0, 8.0)  5回  (13.0, 10.0) 5回  (13.5, 7.5) 4回  ← 太い通路
    (17.5, 2.5)  3回  (16.0, 4.0) 1回   (5.0, 4.5) 1回

  どの所でも 2.5 m 以内に「0.6 m どける余地のあるマス（clear ≥ 850 mm）」が
  8〜17257 個ありました。だから 4 章は**働く場所で働く**見込みです。
  これは「このままでよい」という報告で、直してほしいことではありません。

## (2) ロボットが、地図では通れないマス（free_class 0）に立っていることがある ★決めてほしい

  いちばん大きな見落としだと思います。依頼に決まりが無かったので、こちらで決めました。

  上の 51 回のうち **36 回**は、地図では free_class 0 の所で止まっていました
  （clear は 287〜309 mm しかない）。(10.0,4.5) (10.5,4.5) (17.0,2.5) (19.0,2.5)
  (17.5,2.5) (5.0,4.5) がそれです。

  なぜそうなるか: NavMesh は体の半径 0.25 m で焼いてあるので、卓の角から 0.25 m まで
  面があります。いっぽう free_class は道の幅（0.4 / 0.5 m）で削ってあるので、
  通路の入口で待つ点（AisleWaitPoint）や横へどいた点は NavMesh には乗るけれど
  free_class では 0 です。そこから MapPlanner で道を引こうとすると
  「出発点が通れない」で 1 本も引けません。

  決めたこと（勝手に決めた分）:
    地図は塗り直さず、**出発点だけ近くの通れるマスへ寄せて引く**（mapSnapM 1.0 m 以内、
    いちばん近いマス。同じ距離ならマスの番号の小さい方）。
    寄せた回数は run_info.json の map_snap_starts に出します。
    確かめ 5（上）で、(10.0, 4.5) から 0.088 m 寄せて引けることを見ています。

  残っている気になる所:
    通路の入口で 1 台ずつ入る決まり（aisleYielding）は「変えないもの」なので、
    **待つ点はいまも NavMesh で決めています**（AisleWaitPoint）。つまり
    その点は free_class 0 のことがあり、毎回こちらで寄せが入ります。
    入口の待つ点も地図のマスから選ぶようにするなら、言ってください
    （「変えないもの」に入っているので、勝手には変えませんでした）。

## (3) 依頼に数字が無かったので、こちらで決めた値

  headOnDotMax      −0.5   「向かい合っている」と見る 2 台の向きの内積の上限。
                            −1 が真正面、−0.5 は 120 度より正面寄り
  robotPathCostAdd   6     相手の先の道を重くする強さ（1 歩の重さに足す）。
                            narrowCellCost 3 より大きくして、相手の道を避けさせた
  planMaxExpand  200000    1 回の道探しで広げるマスの上限。超えたら引けないことにする。
                            回が止まらないようにするための保険（ふだんは当たらない）
  mapSnapM         1.0 m   上の (2) の寄せる半径
  planSimplifyMaxM   6 m   曲がり角だけにするとき、近道 1 本の長さの上限
  よける場所の候補  3 つまで・互いに 0.5 m 以上離して、近い順に試す
  robot_yields の数え方
      「よける場所へ動いた回数」＋「相手の道をよけた道に引き直した回数」。
      同じ相手との 1 回の出会いは 1 回だけ数える（近くにいるあいだ 0.5 秒ごとに
      引き直すので、毎回数えると膨らむ）。
      よける場所へ動いた分は yields.csv にも 1 行ずつ出ます（前と同じ形）。

## (4) 角の箱（cornerMin/Max）を MapPlanner で切ると、比べる手がかりが 1 つ減る ★決めてほしい

  依頼どおり MapPlanner では働かないようにしました。ただしこれは
  **測る範囲だけで、動きには何も効きません**（止まった所が角の中かどうかを数えるだけ）。
  切ると summary.json の corner_stops / corner_stop_s / corner_stop_time_share が 0 になり、
  v8b の「止まった時間の約3分の1がこの角」と並べられなくなります。

  残したいなら、ExperimentRunner の 1 行（`&& !robotSpec.UseMapPlanner`）と
  ServiceOps の 1 行を外すだけです。どちらにしますか。
  （今は依頼のとおり「切る」で入っています）

## (5) 回す時間が延びる見込み

  1 回の道探しは ③ 平均 13 ms・最大 35 ms、⑦ 平均 30 ms・最大 79 ms（上の確かめ A）。
  引き直しを 1 台 0.5 秒に 1 回までに抑えたので、最悪で
  2 台 × 2 回/秒 × 30 ms = 120 ms（店の中の 1 秒あたり）。
  v8b の回は 10825 秒を 828 秒（13.1 倍）で回していたので、最悪で **+22 分**くらい。
  ふだんは出会っていないので引き直さないので、実際はもっと少ないはずです。
  run_info.json の plan_ms_max_per_trip で実際の値が見られます。

  重くならないように入れた工夫:
    ・「2 のマスだけで行けるか」は、地図を読んだときに作る「つながりの組」で
      一瞬で見る（A* を空振りさせない）。⑦は卓の半分が狭い道なので、これが効きます。
    ・作業用の配列は作り直さず使い回す。道探しは 2 台で 1 つを使い回す。

## (6) 待機場所は 1 のマス（狭い道）

  待機場所（16.9 / 17.7 / 18.5, 6.05）は壁ぎわなので free_class 1（clear 462 mm）です。
  依頼の「待機場所の真ん中も1以上（壁ぎわなので1になる）」のとおりでした。
  そこへ引くと「狭い道を使った」になりますが、MapPlanner の往復は
  受け渡し口の点（2 のマス）で終わるので、trips.csv の narrow_used には出ません。

  別件で気づいたこと（v9 では触っていません）:
    GoStandby は NavMeshAgent.SetDestination で待機場所へ向かわせていますが、
    NavMeshAgent は updatePosition = false で、Update() は Idle / Done のときに
    先に return します。つまり **transform は動かず、待機場所へ実際には動いていない**
    はずです。4 章の優先の順に「待機場所へ動いている」があるので、
    一度確かめた方がよさそうです。今回は「変えないもの」なので手を入れていません。


====================================================================
4. 組み立ての確かめ
====================================================================

Unity Editor がプロジェクトを押さえているので、Unity を立てずに
Unity 同梱の csc（Roslyn）で ServiceSim の .cs 49 本を**ぜんぶ**組み立てて、
エラーが 0 であることを確かめています（直すたびに毎回）。
なので Unity に切り替えたときに赤い字が出る心配は小さいですが、
Console は見てください（.meta の作られ方など、Unity 側でしか分からない分があります）。


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

1. Unity に切り替える（新しい .cs が取り込まれる）。Console にエラーが無いか見る。
2. ServiceSim/Build Store で ③ を建て直す。
   報告（store_report）に「地図 (map.json): 版 2fa10547a2b7 / マス 960 × 640 …
   JSON の counts と 一致」と「止まる位置: 42 卓すべて 1 以上のマス」が出るはず。
3. ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）を押す。
   上の「2. 確かめ A の結果」と同じ数字が出るはず（③ 0 卓 / ⑦ 24 卓・一致）。
4. 上の「3.」の (2) と (4) を決める。
5. ServiceSim/Site Run/③ スタッフ4人・ロボット2台 を 1 回（7 章 B）。

B で比べる相手は 20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1004（v8b）。
見るのは:
    60 秒の止まりの数と、そのうちロボットどうしの数   v8b: 57 回・51 回
    逃がし弁                                      v8b: あきらめた 61・移した 54・卓を空けた 19
    1 往復で止まった秒（平均）、ロボットが運んだ皿の割合
    detours・robot_yields・detour_extra_m の平均（新しい列）
    最後まで回り切る（全員が店を出る）。来た組は 64 組

B の結果を見せてもらったら、段階Bの18回の回し直しを頼むかどうかを相談します。
B が最後まで回り切ったら Check-in して番号を報告します。


====================================================================
6. 新しいファイルの全文
====================================================================


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/GridPlanner.cs
-------------------------------------------------------------

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットが走れる所の地図（サイトの {名前}_map.json）。
    ///
    /// 2026-10-24 にエイイチと決めたこと（依頼 v9）:
    ///   ロボットは**動ける範囲を地図で持ち、道はその都度自分で引く**。
    ///   地図はサイトが正（版 2fa10547a2b7 で公開）。Unity は塗り直さない・通れる所を足さない。
    ///   NavMesh はロボットの道探しには使わない（人に自分をよけてもらうためだけに残す）。
    ///
    /// マスの意味（JSON の free_class_values そのまま）:
    ///   2 … ふだん走らせる幅の道（normal_width 1.0 m）が通れる
    ///   1 … 通れる幅の道だけ（pass_width 0.8 m）通れる
    ///   0 … 通れない
    /// clear_mm … そのマスから、いちばん近い障害物までの距離（mm）
    ///
    /// 大事な読み方: free_class は「**ロボットの中心を置ける所**」で、
    /// 道の幅のぶんをもう削ってある（体の半径 0.25 m ではなく、道の幅の半分で削ってある）。
    /// だから 1.2 m の通路でも、中心を置ける帯は 0.45 m しかない。
    /// 2 台（中心どうし 0.5 m）は通路の中では並べない。
    ///
    /// 座標: マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res。
    ///       JSON の x → Unity の X、y → Unity の Z。番号は iy*nx+ix。
    /// </summary>
    public class RobotMap
    {
        public readonly string generation = "";
        public readonly float res;
        public readonly int nx, ny;
        public readonly float normalWidthM, passWidthM;

        readonly byte[] _class;
        readonly ushort[] _clearMm;

        /// <summary>JSON が持っている counts（読んだものと突き合わせる）</summary>
        readonly int[] _jsonCounts = new int[3];
        readonly int[] _realCounts = new int[3];

        /// <summary>つながりの番号。2 だけでたどれる組と、1 以上でたどれる組</summary>
        int[] _region2, _region1;

        GridPlanner _planner;

        RobotMap(string generation, float res, int nx, int ny,
                 float normalWidthM, float passWidthM,
                 byte[] cls, ushort[] clearMm, int[] jsonCounts)
        {
            this.generation = generation ?? "";
            this.res = res;
            this.nx = nx;
            this.ny = ny;
            this.normalWidthM = normalWidthM;
            this.passWidthM = passWidthM;
            _class = cls;
            _clearMm = clearMm;
            for (int i = 0; i < 3; i++) _jsonCounts[i] = jsonCounts[i];
            for (int i = 0; i < cls.Length; i++)
            {
                int c = cls[i];
                if (c >= 0 && c <= 2) _realCounts[c]++;
            }
        }

        public int CellCount { get { return nx * ny; } }

        /// <summary>この地図を使う道探し（1つを使い回す。配列が大きいので台ごとに作らない）</summary>
        public GridPlanner Planner
        {
            get
            {
                if (_planner == null) _planner = new GridPlanner(this);
                return _planner;
            }
        }

        // ---------------- 読む ----------------

        /// <summary>
        /// 地図の JSON を読む。読めなければ error に理由を入れて null を返す。
        /// free_class・clear_mm は base64 → gzip を解く（JSON の encoding のとおり）。
        /// </summary>
        public static RobotMap Read(string json, out string error)
        {
            error = null;
            JsonValue root = JsonValue.Parse(json, out error);
            if (root == null || !root.IsObject)
            {
                error = "地図の JSON を読めません: " + error;
                return null;
            }

            float res = root["res"].F(0f);
            int nx = root["nx"].I(0);
            int ny = root["ny"].I(0);
            if (res <= 0.0001f || nx <= 0 || ny <= 0)
            {
                error = "地図の res / nx / ny がありません";
                return null;
            }

            int want = nx * ny;
            byte[] cls = Unpack(root["free_class"].S(""), 1, want, "free_class", out error);
            if (cls == null) return null;
            byte[] raw = Unpack(root["clear_mm"].S(""), 2, want, "clear_mm", out error);
            if (raw == null) return null;

            ushort[] clear = new ushort[want];
            for (int i = 0; i < want; i++)
                clear[i] = (ushort)(raw[i * 2] | (raw[i * 2 + 1] << 8));   // 小さい桁が先

            int[] counts = new int[3];
            JsonValue cj = root["counts"];
            for (int c = 0; c <= 2; c++)
                counts[c] = cj[c.ToString(CultureInfo.InvariantCulture)].I(-1);

            return new RobotMap(root["meta"]["generation"].S(""), res, nx, ny,
                                root["normal_width"].F(0f), root["pass_width"].F(0f),
                                cls, clear, counts);
        }

        static byte[] Unpack(string b64, int bytesPerCell, int cells, string what, out string error)
        {
            error = null;
            if (string.IsNullOrEmpty(b64))
            {
                error = what + " がありません";
                return null;
            }
            byte[] packed;
            try { packed = Convert.FromBase64String(b64); }
            catch (Exception e)
            {
                error = what + " の base64 を解けません: " + e.Message;
                return null;
            }

            byte[] data;
            try
            {
                using (MemoryStream src = new MemoryStream(packed))
                using (GZipStream gz = new GZipStream(src, CompressionMode.Decompress))
                using (MemoryStream dst = new MemoryStream(cells * bytesPerCell))
                {
                    byte[] buf = new byte[1 << 16];
                    int n;
                    while ((n = gz.Read(buf, 0, buf.Length)) > 0) dst.Write(buf, 0, n);
                    data = dst.ToArray();
                }
            }
            catch (Exception e)
            {
                error = what + " の gzip を解けません: " + e.Message;
                return null;
            }

            if (data.Length != cells * bytesPerCell)
            {
                error = what + " の長さが合いません: " + data.Length + " バイト / 要るのは " +
                        (cells * bytesPerCell) + " バイト（nx × ny × " + bytesPerCell + "）";
                return null;
            }
            return data;
        }

        // ---------------- マスを引く ----------------

        public int Index(int ix, int iy) { return iy * nx + ix; }

        /// <summary>その点のマスの番号。店の外なら -1</summary>
        public int CellAt(Vector3 p)
        {
            int ix = Mathf.FloorToInt(p.x / res);
            int iy = Mathf.FloorToInt(p.z / res);
            if (ix < 0 || ix >= nx || iy < 0 || iy >= ny) return -1;
            return iy * nx + ix;
        }

        /// <summary>マスの真ん中（Unity の XZ。y は 0）</summary>
        public Vector3 Center(int index)
        {
            int iy = index / nx;
            int ix = index - iy * nx;
            return new Vector3((ix + 0.5f) * res, 0f, (iy + 0.5f) * res);
        }

        /// <summary>そのマスの種類（2 / 1 / 0）。店の外は 0</summary>
        public int ClassOf(int index)
        {
            return index < 0 || index >= _class.Length ? 0 : _class[index];
        }

        /// <summary>その点の種類（店の外は 0）</summary>
        public int ClassAt(Vector3 p) { return ClassOf(CellAt(p)); }

        /// <summary>いちばん近い障害物までの距離（m）。店の外は 0</summary>
        public float ClearM(int index)
        {
            return index < 0 || index >= _clearMm.Length ? 0f : _clearMm[index] * 0.001f;
        }

        /// <summary>読んだマスの数（種類ごと）</summary>
        public int CountOf(int cls) { return cls >= 0 && cls <= 2 ? _realCounts[cls] : 0; }

        /// <summary>JSON の counts と読んだ数が合っているか</summary>
        public bool CountsMatch
        {
            get
            {
                for (int c = 0; c <= 2; c++)
                    if (_jsonCounts[c] != _realCounts[c]) return false;
                return true;
            }
        }

        /// <summary>
        /// 斜めに進めるか（両隣が通れるときだけ。サイトの道の引き方と同じ）。
        /// ix,iy → jx,jy は斜め隣のこと
        /// </summary>
        public bool DiagonalOpen(int ix, int iy, int jx, int jy, int minClass)
        {
            return _class[iy * nx + jx] >= minClass && _class[jy * nx + ix] >= minClass;
        }

        // ---------------- つながり（2 だけで行けるかを一瞬で見る）----------------

        /// <summary>
        /// 「行き先まで 2 のマスだけで行けるか」を、毎回 A* を空振りさせずに見るための組分け。
        /// 道の引き方（8方向・斜めは両隣が通れるときだけ）とまったく同じつなぎ方で塗る。
        /// </summary>
        public void BuildRegions()
        {
            if (_region2 != null) return;
            _region2 = Flood(2);
            _region1 = Flood(1);
        }

        int[] Flood(int minClass)
        {
            int n = nx * ny;
            int[] region = new int[n];
            for (int i = 0; i < n; i++) region[i] = -1;
            int[] stack = new int[n];
            int next = 0;

            for (int s = 0; s < n; s++)
            {
                if (region[s] >= 0 || _class[s] < minClass) continue;
                int id = next++;
                int top = 0;
                stack[top++] = s;
                region[s] = id;
                while (top > 0)
                {
                    int i = stack[--top];
                    int iy = i / nx;
                    int ix = i - iy * nx;
                    for (int d = 0; d < 8; d++)
                    {
                        int jx = ix + GridPlanner.Dx[d];
                        int jy = iy + GridPlanner.Dy[d];
                        if (jx < 0 || jx >= nx || jy < 0 || jy >= ny) continue;
                        int j = jy * nx + jx;
                        if (region[j] >= 0 || _class[j] < minClass) continue;
                        if (GridPlanner.Dx[d] != 0 && GridPlanner.Dy[d] != 0 &&
                            !DiagonalOpen(ix, iy, jx, jy, minClass)) continue;
                        region[j] = id;
                        stack[top++] = j;
                    }
                }
            }
            return region;
        }

        /// <summary>2 つのマスが、その種類だけでつながっているか</summary>
        public bool SameRegion(int a, int b, int minClass)
        {
            BuildRegions();
            int[] r = minClass >= 2 ? _region2 : _region1;
            if (a < 0 || b < 0 || a >= r.Length || b >= r.Length) return false;
            return r[a] >= 0 && r[a] == r[b];
        }

        // ---------------- 通れるマスへ寄せる ----------------

        /// <summary>
        /// その点のいちばん近い「通れるマス」。
        ///
        /// なぜ要るか: ロボットがいま立っている所が、地図では 0（通れない）のことがある。
        /// NavMesh は体の半径 0.25 m で焼いてあるのに、free_class は道の幅（0.4 m／0.5 m）で
        /// 削ってあるので、通路の端・通路の入口で待つ点・横へどいた点は 0 になる。
        /// （v8b の 20261024_1004 の回では、60 秒の止まり 51 回のうち 36 回がそういう所だった）
        /// 地図は塗り直さず、**出発点だけを近くの通れるマスへ寄せる**。寄せた回数は記録に出す。
        ///
        /// 見つからなければ -1。
        /// </summary>
        public int NearestPassable(Vector3 p, int minClass, float searchM)
        {
            int here = CellAt(p);
            if (here >= 0 && _class[here] >= minClass) return here;

            int ix0 = Mathf.FloorToInt(p.x / res);
            int iy0 = Mathf.FloorToInt(p.z / res);
            int maxR = Mathf.Max(1, Mathf.CeilToInt(searchM / res));

            int best = -1;
            float bestD2 = float.MaxValue;
            for (int r = 1; r <= maxR; r++)
            {
                for (int iy = iy0 - r; iy <= iy0 + r; iy++)
                {
                    if (iy < 0 || iy >= ny) continue;
                    for (int ix = ix0 - r; ix <= ix0 + r; ix++)
                    {
                        if (ix < 0 || ix >= nx) continue;
                        // 輪の上だけ見る（中は前の回で見た）
                        if (Mathf.Abs(ix - ix0) != r && Mathf.Abs(iy - iy0) != r) continue;
                        int i = iy * nx + ix;
                        if (_class[i] < minClass) continue;
                        Vector3 c = Center(i);
                        float dx = c.x - p.x, dz = c.z - p.z;
                        float d2 = dx * dx + dz * dz;
                        // 同じ距離なら番号の小さい方（毎回同じ答えにする）
                        if (d2 < bestD2 - 1e-9f) { bestD2 = d2; best = i; }
                    }
                }
                if (best >= 0) return best;      // 輪を1つ見終わって見つかれば、それが最短
            }
            return best;
        }

        // ---------------- 報告 ----------------

        public string Report()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("  地図 (map.json): 版 " + generation +
                          " / マス " + nx + " × " + ny + " = " + CellCount +
                          " / 1 マス " + F(res) + " m");
            sb.AppendLine("    ふだんの道の幅 " + F(normalWidthM) + " m / 通れる幅 " + F(passWidthM) + " m");
            sb.AppendLine("    マスの数: 2（ふだんの道）" + _realCounts[2] +
                          " / 1（通れる幅だけ）" + _realCounts[1] +
                          " / 0（通れない）" + _realCounts[0] +
                          "  … JSON の counts と " + (CountsMatch ? "一致" : "ちがう") +
                          (CountsMatch ? "" : "（JSON: 2=" + _jsonCounts[2] + " 1=" + _jsonCounts[1] +
                                              " 0=" + _jsonCounts[0] + "）"));
            return sb.ToString().TrimEnd();
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
    }

    /// <summary>
    /// 地図の上で道を引く（依頼 v9 の 2 章）。ロボットだけが使う。NavMesh は使わない。
    ///
    /// 決まり:
    ///   ・8 方向。斜めは両隣が通れるときだけ（サイトと同じ）
    ///   ・行き先まで 2 のマスだけで行けるなら 2 だけで引く。行けなければ 1 も使い、
    ///     1 のマスを narrowCellCost 倍（目安 3 倍）重くする
    ///   ・通路の真ん中寄りを走る: 1 歩の重さに
    ///     centerWeight × max(0, preferClearM − clear) / preferClearM を足す
    ///   ・乱数を使わない。同じ条件なら毎回同じ道（同じ重さのときは h → マスの番号の順）
    ///   ・引けた道は曲がり角の点だけに縮める。近道は「もとの道で通っていた所より
    ///     障害物に近づかない所」だけ（端に寄った線に戻さないため）
    ///
    /// 回り込み・よけのために、その 1 回だけ「通れないマス」「重いマス」を差し込める。
    /// 配列が大きいので、1 つを台どうしで使い回す（道探しはその場で終わるので取り合わない）。
    /// </summary>
    public class GridPlanner
    {
        /// <summary>8 方向の順（決まった順。乱数を使わない）</summary>
        public static readonly int[] Dx = { 1, 1, 0, -1, -1, -1, 0, 1 };
        public static readonly int[] Dy = { 0, 1, 1, 1, 0, -1, -1, -1 };

        /// <summary>道探しの結果</summary>
        public enum Status
        {
            Ok,             // 引けた
            StartBlocked,   // 出発点が通れないマス（寄せても見つからない）
            GoalBlocked,    // 行き先が通れないマス
            NoPath,         // つながっていない
            TooMany         // 探しすぎ（maxExpand を超えた）
        }

        /// <summary>道の引き方の数字（RobotSpec から写す）</summary>
        public struct Settings
        {
            public float narrowCellCost;
            public float centerWeight;
            public float preferClearM;
            public int maxExpand;
            public float snapM;

            /// <summary>曲がり角だけに縮めるとき、1 本の近道の長さの上限（m）</summary>
            public float simplifyMaxM;

            public static Settings Default
            {
                get
                {
                    Settings s = new Settings();
                    s.narrowCellCost = 3f;
                    s.centerWeight = 2f;
                    s.preferClearM = 0.6f;
                    s.maxExpand = 200000;
                    s.snapM = 1.0f;
                    s.simplifyMaxM = 6f;
                    return s;
                }
            }
        }

        readonly RobotMap _map;
        readonly int _nx, _ny, _n;
        readonly float _res;

        // ---- 道探しの作業場（毎回作り直さない）----
        readonly float[] _g;
        readonly int[] _from;
        readonly int[] _seen;          // この回で見たか（_runStamp と比べる）
        readonly int[] _closed;
        int _runStamp;

        // ---- 差し込んだ「通れないマス」「重いマス」----
        readonly int[] _blockStamp;
        readonly int[] _costStamp;
        readonly float[] _costAdd;
        int _markStamp;
        bool _hasMarks;

        // ---- 二分の山（f が小さい順。同じなら h → 番号）----
        int[] _heap;
        float[] _heapF, _heapH;
        int _heapCount;

        readonly List<Vector3> _points = new List<Vector3>();
        readonly List<int> _cells = new List<int>();
        readonly Stopwatch _watch = new Stopwatch();

        public GridPlanner(RobotMap map)
        {
            _map = map;
            _nx = map.nx; _ny = map.ny; _n = map.nx * map.ny; _res = map.res;
            _g = new float[_n];
            _from = new int[_n];
            _seen = new int[_n];
            _closed = new int[_n];
            _blockStamp = new int[_n];
            _costStamp = new int[_n];
            _costAdd = new float[_n];
            _heap = new int[1024];
            _heapF = new float[1024];
            _heapH = new float[1024];
            // 印は 1 から始める。0 のままだと、何も印をつけていないマス（0）が
            // 「印がついている」に見えてしまう
            _markStamp = 1;
            map.BuildRegions();
        }

        public RobotMap Map { get { return _map; } }

        // ---- 直前の結果 ----
        public Status LastStatus { get; private set; }

        /// <summary>引けた道の曲がり角の点（最初は出発のマスの真ん中、最後は行き先のマスの真ん中）</summary>
        public IReadOnlyList<Vector3> Points { get { return _points; } }

        /// <summary>道に 1 のマスが入ったか（＝狭い道を使った）</summary>
        public bool UsedNarrow { get; private set; }

        /// <summary>この 1 回にかかった時間（ミリ秒）</summary>
        public float LastPlanMs { get; private set; }

        /// <summary>広げたマスの数（重さを調べるため）</summary>
        public int LastExpanded { get; private set; }

        /// <summary>マスをたどった長さ（縮める前, m）</summary>
        public float LastRawLengthM { get; private set; }

        /// <summary>縮めたあとの長さ（m）</summary>
        public float LastLengthM { get; private set; }

        /// <summary>出発のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastStartSnapM { get; private set; }

        /// <summary>行き先のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastGoalSnapM { get; private set; }

        /// <summary>
        /// 出発点が、地図では通れないマス（0）だったか。
        /// ロボットは NavMesh（体の半径 0.25 m）で焼いた面を歩くので、
        /// 通路の端・通路の入口で待つ点・横へどいた点は free_class では 0 になる。
        /// そういうときは近くの通れるマスへ寄せて引く。何回あったかを記録に出す
        /// </summary>
        public bool LastStartWasBlocked { get; private set; }

        /// <summary>行き先が、地図では通れないマス（0）だったか</summary>
        public bool LastGoalWasBlocked { get; private set; }

        public string StatusText
        {
            get
            {
                switch (LastStatus)
                {
                    case Status.Ok: return UsedNarrow ? "引けた（狭い道）" : "引けた";
                    case Status.StartBlocked: return "出発点が通れない";
                    case Status.GoalBlocked: return "行き先が通れない";
                    case Status.NoPath: return "つながっていない";
                    default: return "探しすぎ";
                }
            }
        }

        // ---------------- 差し込み（その 1 回だけ）----------------

        /// <summary>差し込んだものをぜんぶ消す（道を引く前に必ず呼ぶ）</summary>
        public void ClearMarks()
        {
            _markStamp++;
            _hasMarks = false;
        }

        /// <summary>丸い所を「通れないマス」にする（人・ほかのロボットの体）</summary>
        public void BlockCircle(Vector3 center, float radiusM)
        {
            MarkCapsule(center, center, radiusM, true, 0f);
        }

        /// <summary>線のまわりを「通れないマス」にする</summary>
        public void BlockLine(Vector3 a, Vector3 b, float radiusM)
        {
            MarkCapsule(a, b, radiusM, true, 0f);
        }

        /// <summary>線のまわりを重くする（相手の先の道から離れて走らせる）</summary>
        public void CostLine(Vector3 a, Vector3 b, float radiusM, float add)
        {
            MarkCapsule(a, b, radiusM, false, add);
        }

        void MarkCapsule(Vector3 a, Vector3 b, float radiusM, bool block, float add)
        {
            if (radiusM <= 0f) return;
            _hasMarks = true;

            float minX = Mathf.Min(a.x, b.x) - radiusM, maxX = Mathf.Max(a.x, b.x) + radiusM;
            float minZ = Mathf.Min(a.z, b.z) - radiusM, maxZ = Mathf.Max(a.z, b.z) + radiusM;
            int ix0 = Mathf.Max(0, Mathf.FloorToInt(minX / _res));
            int ix1 = Mathf.Min(_nx - 1, Mathf.FloorToInt(maxX / _res));
            int iy0 = Mathf.Max(0, Mathf.FloorToInt(minZ / _res));
            int iy1 = Mathf.Min(_ny - 1, Mathf.FloorToInt(maxZ / _res));
            float r2 = radiusM * radiusM;

            for (int iy = iy0; iy <= iy1; iy++)
            {
                for (int ix = ix0; ix <= ix1; ix++)
                {
                    float cx = (ix + 0.5f) * _res, cz = (iy + 0.5f) * _res;
                    if (SegDist2(cx, cz, a.x, a.z, b.x, b.z) > r2) continue;
                    int i = iy * _nx + ix;
                    if (block)
                    {
                        _blockStamp[i] = _markStamp;
                        continue;
                    }
                    if (_costStamp[i] == _markStamp) _costAdd[i] = Mathf.Max(_costAdd[i], add);
                    else { _costStamp[i] = _markStamp; _costAdd[i] = add; }
                }
            }
        }

        static float SegDist2(float px, float pz, float ax, float az, float bx, float bz)
        {
            float vx = bx - ax, vz = bz - az;
            float l2 = vx * vx + vz * vz;
            float t = 0f;
            if (l2 > 1e-12f)
            {
                t = ((px - ax) * vx + (pz - az) * vz) / l2;
                t = t < 0f ? 0f : (t > 1f ? 1f : t);
            }
            float dx = px - (ax + t * vx), dz = pz - (az + t * vz);
            return dx * dx + dz * dz;
        }

        // ---------------- 道を引く ----------------

        /// <summary>
        /// from から to まで道を引く。引けたら Status.Ok で、Points に曲がり角の点が入る。
        ///
        /// 「2 のマスだけで行けるなら 2 だけ」は、つながりの組（RobotMap.SameRegion）で
        /// 一瞬で見る。A* を空振りさせない（⑦では卓の半分が狭い道なので、空振りが重い）。
        /// </summary>
        public Status Plan(Vector3 from, Vector3 to, Settings st)
        {
            _watch.Reset();
            _watch.Start();

            _points.Clear();
            _cells.Clear();
            UsedNarrow = false;
            LastExpanded = 0;
            LastRawLengthM = 0f;
            LastLengthM = 0f;
            LastStartSnapM = 0f;
            LastGoalSnapM = 0f;
            LastStartWasBlocked = _map.ClassAt(from) < 1;
            LastGoalWasBlocked = _map.ClassAt(to) < 1;

            // 行き先は、まず 1 以上のマスに乗っているかを見る（サイトが通れる所に置いている）
            int goal = _map.NearestPassable(to, 1, st.snapM);
            if (goal < 0)
            {
                LastStatus = Status.GoalBlocked;
                return Done();
            }
            LastGoalSnapM = Flat(_map.Center(goal) - to).magnitude;

            int start = _map.NearestPassable(from, 1, st.snapM);
            if (start < 0)
            {
                LastStatus = Status.StartBlocked;
                return Done();
            }
            LastStartSnapM = Flat(_map.Center(start) - from).magnitude;

            // 2 のマスだけで行けるか（出発点・行き先がどちらも 2 で、同じ組）
            int minClass = 1;
            if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                _map.SameRegion(start, goal, 2) && !_hasMarks)
            {
                minClass = 2;
            }
            else if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                     _map.SameRegion(start, goal, 2))
            {
                // 差し込み（回り込み・よけ）があるときは、つながりの組では決められない。
                // まず 2 だけで試し、だめなら 1 も使う
                if (Search(start, goal, 2, st) == Status.Ok)
                {
                    Finish(st, 2);
                    LastStatus = Status.Ok;
                    return Done();
                }
            }

            if (minClass == 1 && !_map.SameRegion(start, goal, 1))
            {
                LastStatus = Status.NoPath;
                return Done();
            }

            Status s = Search(start, goal, minClass, st);
            if (s != Status.Ok)
            {
                LastStatus = s;
                return Done();
            }
            Finish(st, minClass);
            LastStatus = Status.Ok;
            return Done();
        }

        Status Done()
        {
            _watch.Stop();
            LastPlanMs = (float)(_watch.Elapsed.TotalMilliseconds);
            return LastStatus;
        }

        Status Search(int start, int goal, int minClass, Settings st)
        {
            _runStamp++;
            _heapCount = 0;
            _cells.Clear();

            if (Blocked(start, minClass) || Blocked(goal, minClass)) return Status.NoPath;

            int gy = goal / _nx, gx = goal - gy * _nx;
            float prefMm = Mathf.Max(0.001f, st.preferClearM);
            float d2 = _res * 1.41421356f;
            int expanded = 0;

            _g[start] = 0f;
            _from[start] = -1;
            _seen[start] = _runStamp;
            Push(start, Heuristic(start, gx, gy, d2), Heuristic(start, gx, gy, d2));

            while (_heapCount > 0)
            {
                int i = Pop();
                if (_closed[i] == _runStamp) continue;
                _closed[i] = _runStamp;
                expanded++;
                if (i == goal)
                {
                    LastExpanded = expanded;
                    Trace(start, goal);
                    return Status.Ok;
                }
                if (expanded > st.maxExpand)
                {
                    LastExpanded = expanded;
                    return Status.TooMany;
                }

                int iy = i / _nx, ix = i - iy * _nx;
                float gi = _g[i];

                for (int d = 0; d < 8; d++)
                {
                    int jx = ix + Dx[d], jy = iy + Dy[d];
                    if (jx < 0 || jx >= _nx || jy < 0 || jy >= _ny) continue;
                    int j = jy * _nx + jx;
                    if (_closed[j] == _runStamp) continue;
                    if (Blocked(j, minClass)) continue;

                    float step;
                    if (Dx[d] != 0 && Dy[d] != 0)
                    {
                        // 斜めは両隣が通れるときだけ
                        if (Blocked(iy * _nx + jx, minClass) || Blocked(jy * _nx + ix, minClass)) continue;
                        step = d2;
                    }
                    else step = _res;

                    float mult = _map.ClassOf(j) == 1 ? st.narrowCellCost : 1f;
                    float clearM = _map.ClearM(j);
                    if (clearM < st.preferClearM)
                        mult += st.centerWeight * (st.preferClearM - clearM) / prefMm;
                    if (_costStamp[j] == _markStamp) mult += _costAdd[j];

                    float ng = gi + step * mult;
                    if (_seen[j] == _runStamp && ng >= _g[j] - 1e-9f) continue;

                    _g[j] = ng;
                    _from[j] = i;
                    _seen[j] = _runStamp;
                    float h = Heuristic(j, gx, gy, d2);
                    Push(j, ng + h, h);
                }
            }

            LastExpanded = expanded;
            return Status.NoPath;
        }

        /// <summary>そのマスは通れないか（地図の種類＋差し込んだ「通れないマス」）</summary>
        bool Blocked(int i, int minClass)
        {
            if (_map.ClassOf(i) < minClass) return true;
            return _hasMarks && _blockStamp[i] == _markStamp;
        }

        /// <summary>残りの見込み（8 方向のまっすぐな長さ）。1 歩の重さの下限は 1 倍なので、下から見ている</summary>
        float Heuristic(int i, int gx, int gy, float d2)
        {
            int iy = i / _nx, ix = i - iy * _nx;
            int dx = ix > gx ? ix - gx : gx - ix;
            int dy = iy > gy ? iy - gy : gy - iy;
            int lo = dx < dy ? dx : dy;
            return _res * (dx + dy) + (d2 - 2f * _res) * lo;
        }

        void Trace(int start, int goal)
        {
            _cells.Clear();
            int i = goal;
            while (i != start)
            {
                _cells.Add(i);
                i = _from[i];
                if (i < 0) break;
            }
            _cells.Add(start);
            _cells.Reverse();
        }

        void Finish(Settings st, int minClass)
        {
            // 1 のマスが入ったか
            for (int k = 0; k < _cells.Count; k++)
                if (_map.ClassOf(_cells[k]) == 1) { UsedNarrow = true; break; }

            float raw = 0f;
            for (int k = 1; k < _cells.Count; k++)
                raw += Flat(_map.Center(_cells[k]) - _map.Center(_cells[k - 1])).magnitude;
            LastRawLengthM = raw;

            Simplify(st, minClass);

            float len = 0f;
            for (int k = 1; k < _points.Count; k++)
                len += Flat(_points[k] - _points[k - 1]).magnitude;
            LastLengthM = len;
        }

        // ---------------- 曲がり角の点だけにする ----------------

        /// <summary>
        /// マスの並びを曲がり角の点だけに縮める。
        /// 近道してよいのは「もとの道で通っていた所より障害物に近づかない」ときだけ
        /// （＝近道の上のどのマスも、飛ばしたマスのいちばん狭い所より clear が大きい）。
        /// 端に寄った線に戻らないようにするため。
        /// </summary>
        void Simplify(Settings st, int minClass)
        {
            _points.Clear();
            if (_cells.Count == 0) return;
            _points.Add(_map.Center(_cells[0]));
            if (_cells.Count == 1) return;

            int step = Mathf.Max(1, Mathf.RoundToInt(0.1f / _res));   // 0.1 m ごとに試す
            int maxCells = Mathf.Max(step, Mathf.RoundToInt(st.simplifyMaxM / _res));

            int i = 0;
            while (i < _cells.Count - 1)
            {
                int best = i + 1;
                float floorM = Mathf.Min(_map.ClearM(_cells[i]), _map.ClearM(_cells[i + 1]));

                int j = i + step;
                float runFloor = floorM;
                while (j < _cells.Count && j - i <= maxCells)
                {
                    for (int k = Mathf.Max(i + 1, j - step + 1); k <= j; k++)
                        runFloor = Mathf.Min(runFloor, _map.ClearM(_cells[k]));
                    if (!SegmentOk(_cells[i], _cells[j], runFloor, minClass)) break;
                    best = j;
                    j += step;
                }
                // 最後の点までちょうど届くなら、そこで終わる
                int last = _cells.Count - 1;
                if (best < last && last - i <= maxCells)
                {
                    float f = runFloor;
                    for (int k = best + 1; k <= last; k++) f = Mathf.Min(f, _map.ClearM(_cells[k]));
                    if (SegmentOk(_cells[i], _cells[last], f, minClass)) best = last;
                }

                _points.Add(_map.Center(_cells[best]));
                i = best;
            }
        }

        /// <summary>まっすぐ結んだ線が通れて、もとの道より障害物に近づかないか</summary>
        bool SegmentOk(int a, int b, float floorM, int minClass)
        {
            Vector3 pa = _map.Center(a), pb = _map.Center(b);
            float len = Flat(pb - pa).magnitude;
            int n = Mathf.Max(1, Mathf.CeilToInt(len / (_res * 0.5f)));
            for (int k = 0; k <= n; k++)
            {
                float t = (float)k / n;
                float x = pa.x + (pb.x - pa.x) * t;
                float z = pa.z + (pb.z - pa.z) * t;
                int ix = Mathf.FloorToInt(x / _res);
                int iy = Mathf.FloorToInt(z / _res);
                if (ix < 0 || ix >= _nx || iy < 0 || iy >= _ny) return false;
                int i = iy * _nx + ix;
                if (Blocked(i, minClass)) return false;
                if (_map.ClearM(i) < floorM - 1e-6f) return false;
                if (_hasMarks && _costStamp[i] == _markStamp) return false;   // 重い所へは近道しない
            }
            return true;
        }

        // ---------------- 二分の山 ----------------

        void Push(int index, float f, float h)
        {
            if (_heapCount == _heap.Length) Grow();
            int k = _heapCount++;
            _heap[k] = index; _heapF[k] = f; _heapH[k] = h;
            while (k > 0)
            {
                int p = (k - 1) >> 1;
                if (!Less(k, p)) break;
                Swap(k, p);
                k = p;
            }
        }

        int Pop()
        {
            int top = _heap[0];
            _heapCount--;
            if (_heapCount > 0)
            {
                _heap[0] = _heap[_heapCount];
                _heapF[0] = _heapF[_heapCount];
                _heapH[0] = _heapH[_heapCount];
                int k = 0;
                while (true)
                {
                    int l = (k << 1) + 1, r = l + 1, m = k;
                    if (l < _heapCount && Less(l, m)) m = l;
                    if (r < _heapCount && Less(r, m)) m = r;
                    if (m == k) break;
                    Swap(k, m);
                    k = m;
                }
            }
            return top;
        }

        /// <summary>同じ重さのときの並べ方を決めておく（f → h → マスの番号）。乱数を使わない</summary>
        bool Less(int a, int b)
        {
            if (_heapF[a] < _heapF[b] - 1e-9f) return true;
            if (_heapF[a] > _heapF[b] + 1e-9f) return false;
            if (_heapH[a] < _heapH[b] - 1e-9f) return true;
            if (_heapH[a] > _heapH[b] + 1e-9f) return false;
            return _heap[a] < _heap[b];
        }

        void Swap(int a, int b)
        {
            int i = _heap[a]; _heap[a] = _heap[b]; _heap[b] = i;
            float f = _heapF[a]; _heapF[a] = _heapF[b]; _heapF[b] = f;
            float h = _heapH[a]; _heapH[a] = _heapH[b]; _heapH[b] = h;
        }

        void Grow()
        {
            int size = _heap.Length * 2;
            int[] hi = new int[size];
            float[] hf = new float[size];
            float[] hh = new float[size];
            Array.Copy(_heap, hi, _heapCount);
            Array.Copy(_heapF, hf, _heapCount);
            Array.Copy(_heapH, hh, _heapCount);
            _heap = hi; _heapF = hf; _heapH = hh;
        }

        static Vector3 Flat(Vector3 v) { v.y = 0f; return v; }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/MapPlanCheck.cs
-------------------------------------------------------------

using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 確かめ A（依頼 v9 の 7 章 A）：人のいない店で、受け渡し口から全卓へ道を引く。
    ///
    /// 見るもの:
    ///   ・狭い道を使った卓が、サイトの routes の use と同じか（③は 0 卓・⑦は 24 卓）
    ///   ・引いた道の長さと、サイトの routes の length の比（最小・平均・最大）
    ///   ・道探しの時間（平均・中央・最大）
    ///   ・道が引けない卓が無いこと。待機場所へも引けること
    ///
    /// 店も人も建てずに、JSON と地図だけで回せるようにしてある。
    /// Unity のメニュー（ServiceSim/Map Planner）と、Unity の外（mono）の両方から
    /// **同じこの関数**を呼ぶので、出る数字は必ず同じになる。
    /// </summary>
    public static class MapPlanCheck
    {
        /// <summary>1 卓ぶんの結果</summary>
        public struct Row
        {
            public string tableId;
            public string siteUse;      // サイトの routes の use（normal / narrow）
            public string planUse;      // 引いた道（normal / narrow）
            public float siteLengthM;
            public float planLengthM;
            public float ratio;         // 引いた道 ÷ サイトの length
            public int corners;
            public float planMs;
            public string status;
        }

        /// <summary>1 店ぶんの結果</summary>
        public class Result
        {
            public bool ok;
            public string note = "";
            public string report = "";
            public readonly List<Row> rows = new List<Row>();
            public int narrowMine, narrowSite, failed, mismatch, snapped;
            public float ratioMin, ratioMean, ratioMax;
            public float planMsMean, planMsMedian, planMsMax;
            public int standbyOk, standbyCount;

            /// <summary>CSV（table,...）。そのまま書き出せる</summary>
            public string Csv()
            {
                StringBuilder sb = new StringBuilder();
                sb.Append("table,site_use,plan_use,site_length_m,plan_length_m,ratio,corners,plan_ms,status\n");
                for (int i = 0; i < rows.Count; i++)
                {
                    Row r = rows[i];
                    sb.Append(r.tableId).Append(',').Append(r.siteUse).Append(',').Append(r.planUse).Append(',')
                      .Append(F(r.siteLengthM)).Append(',').Append(F(r.planLengthM)).Append(',')
                      .Append(F(r.ratio)).Append(',').Append(r.corners).Append(',')
                      .Append(F(r.planMs)).Append(',').Append(r.status).Append('\n');
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// 回す。storeJson は店の形の JSON、mapJsonText は地図の JSON。
        /// spec が null なら GridPlanner.Settings.Default で引く。
        /// </summary>
        public static Result Run(string label, string storeJson, string mapJsonText, RobotSpec spec)
        {
            Result res = new Result();
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## " + label);

            string err;
            JsonValue store = JsonValue.Parse(storeJson, out err);
            if (store == null || !store.IsObject)
            {
                res.note = "店の形の JSON を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }

            RobotMap map = RobotMap.Read(mapJsonText, out err);
            if (map == null)
            {
                res.note = "地図を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine(map.Report());

            GridPlanner.Settings st = spec != null ? spec.PlannerSettings() : GridPlanner.Settings.Default;
            sb.AppendLine("  道の引き方: narrowCellCost " + F(st.narrowCellCost) +
                          " / centerWeight " + F(st.centerWeight) +
                          " / preferClearM " + F(st.preferClearM) +
                          " / maxExpand " + st.maxExpand +
                          " / snapM " + F(st.snapM) +
                          " / simplifyMaxM " + F(st.simplifyMaxM));

            Vector3 pass = Vector3.zero;
            bool hasPass = false;
            List<Vector3> standby = new List<Vector3>();
            JsonValue places = store["places"];
            for (int i = 0; i < places.Count; i++)
            {
                string kind = places[i]["kind"].S("");
                Vector3 p = new Vector3(places[i]["point"][0].F(0f), 0f, places[i]["point"][1].F(0f));
                if (kind == "FoodPass") { pass = p; hasPass = true; }
                else if (kind == "RobotStandby") standby.Add(p);
            }
            if (!hasPass)
            {
                res.note = "places に FoodPass（受け渡し口の前）がありません";
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine("  受け渡し口の前 " + P(pass) + " → マスの種類 " + map.ClassAt(pass));

            GridPlanner planner = map.Planner;
            JsonValue fromPass = store["routes"]["from_pass"];
            JsonValue tables = store["tables"];

            List<float> ratios = new List<float>();
            List<float> times = new List<float>();
            List<string> mismatchList = new List<string>();

            for (int i = 0; i < tables.Count; i++)
            {
                Row row = new Row();
                row.tableId = tables[i]["id"].S("T" + (i + 1).ToString("00"));
                Vector3 stop = new Vector3(tables[i]["stop"][0].F(0f), 0f, tables[i]["stop"][1].F(0f));

                JsonValue route = fromPass[row.tableId];
                row.siteUse = route["use"].S("");
                row.siteLengthM = route["length"].F(-1f);
                if (row.siteUse == "narrow") res.narrowSite++;

                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, stop, st);
                row.planMs = planner.LastPlanMs;
                times.Add(row.planMs);
                if (planner.LastStartWasBlocked || planner.LastGoalWasBlocked) res.snapped++;

                if (s != GridPlanner.Status.Ok)
                {
                    res.failed++;
                    row.status = planner.StatusText;
                    res.rows.Add(row);
                    sb.AppendLine("  × " + row.tableId + " 道が引けません: " + row.status);
                    continue;
                }

                row.planUse = planner.UsedNarrow ? "narrow" : "normal";
                if (planner.UsedNarrow) res.narrowMine++;
                row.planLengthM = planner.LastLengthM;
                row.corners = planner.Points.Count;
                row.status = "ok";
                row.ratio = row.siteLengthM > 0.0005f ? row.planLengthM / row.siteLengthM : -1f;
                if (row.ratio > 0f) ratios.Add(row.ratio);
                if (row.planUse != row.siteUse)
                {
                    res.mismatch++;
                    mismatchList.Add(row.tableId + "（サイト " + row.siteUse + " / 引いた道 " + row.planUse + "）");
                }
                res.rows.Add(row);
            }

            sb.AppendLine("  狭い道を使った卓: 引いた道 " + res.narrowMine +
                          " 卓 / サイトの routes の use " + res.narrowSite + " 卓  → " +
                          (res.mismatch == 0 ? "一致" : "ちがう（" + res.mismatch + " 卓）"));
            for (int i = 0; i < mismatchList.Count && i < 12; i++)
                sb.AppendLine("    - " + mismatchList[i]);
            if (mismatchList.Count > 12)
                sb.AppendLine("    - ほか " + (mismatchList.Count - 12) + " 卓");
            sb.AppendLine("  道が引けない卓: " + res.failed + " / " + tables.Count);

            if (ratios.Count > 0)
            {
                ratios.Sort();
                float sum = 0f;
                for (int i = 0; i < ratios.Count; i++) sum += ratios[i];
                res.ratioMin = ratios[0];
                res.ratioMean = sum / ratios.Count;
                res.ratioMax = ratios[ratios.Count - 1];
                sb.AppendLine("  長さの比（引いた道 ÷ サイトの length）: 最小 " + F(res.ratioMin) +
                              " / 平均 " + F(res.ratioMean) + " / 最大 " + F(res.ratioMax) +
                              "（真ん中寄りにした分だけ延びる）");
            }
            if (times.Count > 0)
            {
                times.Sort();
                float sum = 0f;
                for (int i = 0; i < times.Count; i++) sum += times[i];
                res.planMsMean = sum / times.Count;
                res.planMsMedian = times[times.Count / 2];
                res.planMsMax = times[times.Count - 1];
                sb.AppendLine("  道探しの時間: 平均 " + F(res.planMsMean) + " ms / 中央 " +
                              F(res.planMsMedian) + " ms / 最大 " + F(res.planMsMax) + " ms");
            }
            sb.AppendLine("  出発点・行き先が地図で通れないマス（0）だったので寄せた卓: " +
                          res.snapped + " / " + tables.Count);

            res.standbyCount = standby.Count;
            for (int i = 0; i < standby.Count; i++)
            {
                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, standby[i], st);
                bool good = s == GridPlanner.Status.Ok;
                if (good) res.standbyOk++;
                sb.AppendLine("  待機場所" + (i + 1) + " " + P(standby[i]) +
                              " マスの種類 " + map.ClassAt(standby[i]) + " → " +
                              (good ? "引けた（" + F(planner.LastLengthM) + " m・" +
                                      F(planner.LastPlanMs) + " ms・" +
                                      (planner.UsedNarrow ? "狭い道を使った" : "ふだんの道") + "）"
                                    : "引けない：" + planner.StatusText));
            }

            res.ok = res.failed == 0 && res.mismatch == 0 &&
                     res.standbyOk == res.standbyCount && map.CountsMatch;
            sb.AppendLine("  → " + (res.ok ? "確かめ A は通った" : "確かめ A は通っていない"));
            res.report = sb.ToString().TrimEnd();
            return res;
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
        static string P(Vector3 v) { return "(" + F(v.x) + ", " + F(v.z) + ")"; }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Robot/RobotPathLine.cs
-------------------------------------------------------------

using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットがいま引いている道を、床の上に細い線で出す（依頼 v9 の 6 章）。
    ///
    /// 色は仕事で変える:
    ///   料理を載せているとき（卓へ向かっている）… だいだい
    ///   空で戻るとき・待機 … 水色
    ///
    /// 見た目だけ。当たりも NavMesh も持たないので、
    /// ロボットのセンサー・人のよけ方・道探しには入らない。
    /// RobotSpec.showPathLine を外すと出さない（測る回でも出してよいが、切れるようにする）。
    ///
    /// 置き場所: ロボットの prefab には足さない。ロボットが自分で子として作る
    /// （材料も prefab も要らないようにして、シーンの組み立てを変えないため）。
    /// </summary>
    [RequireComponent(typeof(LineRenderer))]
    public class RobotPathLine : MonoBehaviour
    {
        /// <summary>この部品を置く子の名前</summary>
        public const string RootName = "PathLine";

        static readonly Color CarryColor = new Color(1f, 0.55f, 0.1f, 0.9f);   // 料理を載せている
        static readonly Color EmptyColor = new Color(0.25f, 0.75f, 1f, 0.9f);  // 空で戻る

        LineRenderer _line;
        readonly List<Vector3> _points = new List<Vector3>();

        /// <summary>
        /// ロボットの下に線を作る（無ければ）。材料はその場で作るので、
        /// prefab や .asset をいじらなくていい。URP でも読める shader を順に試す
        /// </summary>
        public static RobotPathLine Attach(Transform robot, float widthM)
        {
            if (robot == null) return null;
            Transform t = robot.Find(RootName);
            GameObject go;
            if (t != null) go = t.gameObject;
            else
            {
                go = new GameObject(RootName);
                go.transform.SetParent(robot, false);
            }

            if (go.GetComponent<LineRenderer>() == null) go.AddComponent<LineRenderer>();
            RobotPathLine p = go.GetComponent<RobotPathLine>();
            if (p == null) p = go.AddComponent<RobotPathLine>();
            p.Setup(widthM);
            return p;
        }

        void Setup(float widthM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.useWorldSpace = true;          // 親が回っても線は曲がらない
            _line.alignment = LineAlignment.View;
            _line.textureMode = LineTextureMode.Stretch;
            _line.numCapVertices = 2;
            _line.numCornerVertices = 2;
            _line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
            _line.receiveShadows = false;
            _line.widthMultiplier = Mathf.Max(0.005f, widthM);
            _line.positionCount = 0;

            if (_line.sharedMaterial == null)
            {
                Shader s = Shader.Find("Universal Render Pipeline/Unlit");
                if (s == null) s = Shader.Find("Sprites/Default");
                if (s == null) s = Shader.Find("Unlit/Color");
                if (s != null)
                {
                    Material m = new Material(s);
                    m.name = "Mat_RobotPathLine";
                    _line.sharedMaterial = m;
                }
            }
        }

        /// <summary>
        /// 線を出し直す。points は道の曲がり角（世界の座標）。
        /// carrying は料理を載せているか。heightM は床から浮かせる高さ。
        /// </summary>
        public void Show(IReadOnlyList<Vector3> points, int fromIndex, Vector3 here,
                         bool carrying, float heightM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();

            _points.Clear();
            _points.Add(new Vector3(here.x, heightM, here.z));
            if (points != null)
                for (int i = Mathf.Max(0, fromIndex); i < points.Count; i++)
                    _points.Add(new Vector3(points[i].x, heightM, points[i].z));

            if (_points.Count < 2)
            {
                _line.positionCount = 0;
                return;
            }

            _line.positionCount = _points.Count;
            for (int i = 0; i < _points.Count; i++) _line.SetPosition(i, _points[i]);

            Color c = carrying ? CarryColor : EmptyColor;
            _line.startColor = c;
            _line.endColor = new Color(c.r, c.g, c.b, c.a * 0.35f);
            if (_line.sharedMaterial != null && _line.sharedMaterial.HasProperty("_BaseColor"))
                _line.sharedMaterial.SetColor("_BaseColor", c);
        }

        /// <summary>線を消す</summary>
        public void Hide()
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.positionCount = 0;
        }
    }
}


-------------------------------------------------------------
--- Assets/ServiceSim/Editor/ServiceSimMapPlanner.cs
-------------------------------------------------------------

using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 地図の上で道を引く確かめ（依頼 v9 の 7 章 A）。
    ///
    /// 店も人も建てず、回さない。サイトの JSON と地図だけで、
    /// 受け渡し口から全卓へ道を引いて、サイトの routes と突き合わせる。
    /// 中身は ServiceSim.MapPlanCheck（Unity の外からも同じものを呼べる）。
    /// </summary>
    public static class ServiceSimMapPlanner
    {
        const string P1Path = "Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset";
        const string P5Path = "Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset";

        [MenuItem("ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）", false, 34)]
        public static void CheckBoth()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("# 確かめ A：人のいない店で、受け渡し口から全卓へ道を引く（依頼 v9 の 7 章 A）");
            sb.AppendLine("作った日時: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            sb.AppendLine("店も人も建てていない（JSON と地図だけで引いた）");
            sb.AppendLine();

            RobotSpec spec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            if (spec == null)
                sb.AppendLine("※ RobotSpec が見つからないので、道の引き方は既定の値で引きました");

            bool ok = true;
            ok &= One(sb, P1Path, "③ 効率重視型・入口が左下の角", spec);
            sb.AppendLine();
            ok &= One(sb, P5Path, "⑦ ぎっしり型", spec);

            sb.AppendLine();
            sb.AppendLine(ok ? "→ ③⑦ ともに確かめ A は通った" : "→ 通っていないものがある（上の × を見てください）");

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir))
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string txt = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_planner_checkA.txt");
                    File.WriteAllText(txt, sb.ToString(), new UTF8Encoding(false));
                    sb.AppendLine("書き出し: " + txt);
                }
                catch (Exception e) { sb.AppendLine("書き出せません: " + e.Message); }
            }

            if (ok) Debug.Log("[ServiceSim] " + sb.ToString());
            else Debug.LogError("[ServiceSim] " + sb.ToString());
        }

        static bool One(StringBuilder sb, string assetPath, string label, RobotSpec spec)
        {
            JsonStoreLayout layout = AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath);
            if (layout == null)
            {
                sb.AppendLine("## " + label + "\n  店の形が見つかりません: " + assetPath);
                return false;
            }
            if (layout.layoutJson == null || layout.mapJson == null)
            {
                sb.AppendLine("## " + label + "（" + layout.layoutFileName + "）");
                sb.AppendLine("  " + (layout.layoutJson == null ? "layoutJson" : "mapJson") +
                              " が入っていません（依頼 v9 の 0 章を確かめてください）");
                return false;
            }

            MapPlanCheck.Result res = MapPlanCheck.Run(
                label + "（" + layout.layoutFileName + "）",
                layout.layoutJson.text, layout.mapJson.text, spec);
            sb.AppendLine(res.report);

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir) && res.rows.Count > 0)
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string csv = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_plan_" +
                        layout.layoutFileName + ".csv");
                    File.WriteAllText(csv, res.Csv(), new UTF8Encoding(false));
                    sb.AppendLine("  CSV: " + csv);
                }
                catch (Exception e) { sb.AppendLine("  CSV を書けません: " + e.Message); }
            }
            return res.ok;
        }
    }
}
```

### 12:49 確かめ A の報告（チャットに貼られたもの）

```text
v9 の実装は揃っていて、確かめ A は③⑦とも通りました。B（回す確認）はエイイチに押
  していただく必要があるので、そこで止めます。

  0章 版の突き合わせ ✅

  summary.json 6店すべて      2fa10547a2b7
  p1 / p5 の店の JSON          2fa10547a2b7
  p1 / p5 の _map.json         2fa10547a2b7
  params layout_generation     2fa10547a2b7
  summary.json
  は店ごとの配列だったので、6店すべてを個別に見ています。narrow_tables も ③=0 /
  ⑦=24 で指示書と一致。

  2つの地図は free_class の sha256 が別（e2078fd6… /
  aed1e0f1…）で、マスの数が偶然同一（2:148223 / 1:30397 /
  0:435780）でした。取り違えではありません。gzip を解いた実数も JSON の counts
  と一致します。

  確かめ A（人のいない店で・Unity の外から回しました）

  Unity がプロジェクトを押さえているので、MapPlanCheck.Run() を mono 
  で直接呼びました。これは Unity のメニューが呼ぶのと同じ関数で、spec = null →
  GridPlanner.Settings.Default に落ちます。その既定値が RobotSpec.cs
  の既定と1つずつ同じであることを確認したので、.asset（narrowCellCost 
  3・centerWeight 2・preferClearM 0.6）と同じ条件です。

  ┌─────────────────────────┬──────────────────────────────┬────────────────┐
  │                         │    ③ p1_efficient_corner     │  ⑦ p5_crammed  │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 狭い道を使った卓        │ 0 卓（サイト 0）✅           │ 24 卓（サイト  │
  │                         │                              │ 24）✅         │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 道が引けない卓          │ 0 / 42                       │ 0 / 42         │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 長さの比（最小／平均／  │ 0.959 / 1.019 / 1.057        │ 0.959 / 1.037  │
  │ 最大）                  │                              │ / 1.227        │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 道探しの時間（平均／中  │ 14.7 / 12.3 / 39.5 ms        │ 35.0 / 27.4 /  │
  │ 央／最大）              │                              │ 93.5 ms        │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 寄せが要った卓（マス 0  │ 0 / 42                       │ 0 / 42         │
  │ だった）                │                              │                │
  ├─────────────────────────┼──────────────────────────────┼────────────────┤
  │ 待機場所3つ             │ すべて引けた（マスの種類1・  │ すべて引けた（ │
  │                         │ 狭い道扱い）                 │ 同じ）         │
  └─────────────────────────┴──────────────────────────────┴────────────────┘
  
  真ん中寄りにした分の延びは平均 +1.9%（③）／+3.7%（⑦）。いちばん延びたのは ⑦
  の卓T12（16.367 → 20.082 m・比 1.227）です。

  1点だけ、指示書の見込みと違う所

  指示書は「真ん中寄りにした分だけ延びるはず」とありましたが、比が 1 未満の卓が 
  ③で3卓・⑦で4卓ありました。

  卓T42  site 5.379 → plan 5.159  比 0.959
  卓T35  site 3.804 → plan 3.790  比 0.996
  卓T34  site 3.763 → plan 3.751  比 0.997
  卓T28  site 3.275 → plan 3.230  比 0.986（⑦のみ）
  いずれも3〜5 m の短い道で、8方向のマス目が角をわずかに斜めに切れる分です（use
  は site/plan とも normal
  で一致）。地図を塗り替えたり通れる所を足したりはしていないので、作りの問題では
  なく測り方の差と見ています。報告だけに留め、コードは触っていません。

  実装の点検（3〜6章）

  ┌──────────────┬───────────────────────────────────────────────────────────┐
  │      章      │                           状態                            │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │ 1 地図を読む │ JsonStoreLayout.mapJson・RobotMap.Read。counts            │
  │              │ 照合・受け渡し口・全卓・待機場所の確かめ入り ✅           │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │ 2 道の引き方 │ GridPlanner.cs（新規・38 KB）。pathMode（既定             │
  │              │ MapPlanner）で切り替え ✅                                 │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │              │ detourAfterSec                                            │
  │ 3 回り込み   │ 3秒・近くの人/台を1回だけ通れないマスに・escape           │
  │              │ と10秒引き直しは MapPlanner で未使用 ✅                   │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │ 4 ロボットど │ UpdateRobotYieldMap（料理を載せている方が先）。SiteRoutes │
  │ うし         │  では従来の UpdateRobotYield ✅                           │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │ 5 記録       │ trips.csv の9列すべて・stops.csv の                       │
  │              │ Detoured/YieldedToRobot・run_info の6項目 ✅              │
  ├──────────────┼───────────────────────────────────────────────────────────┤
  │ 6 見せる     │ RobotPathLine.Attach(...) で床の線、キッチンのモニターに  │
  │              │ 「走る／一時停止／回り込む／よける」 ✅                   │
  └──────────────┴───────────────────────────────────────────────────────────┘

  「SiteRoutes のときだけ働く」の切り分けも確認しました — 戻りの線（UseMapPlanner
  で早期 return）・角の箱（ServiceOps で
  !UseMapPlanner）・旧「横へどく」（UpdateRobotYield と UpdateRobotYieldMap
  の分岐）。地図が無いのに MapPlanner のときは LogError を出して SiteRoutes
  に落ちます。

  新しい4ファイルに .meta が無かったので作って VCS に登録しました（guid
  は既存と重複なし）。残る Private は Mat_MonitorBoard 
  の2件だけで、コンパイルはランタイム・Editor ともエラー・警告ゼロです。

  ---
  お願い：確かめ B を回してください
  
  Unity がプロジェクトを開いているので私からは回せません。一度 Unity 
  に切り替えて（新しい4ファイルが取り込まれて再コンパイルされます）、

  ServiceSim / Site Run / ③ スタッフ4人・ロボット2台 を1回

  終わったら「終わった」と教えてください。比べる相手（v8b の
  ..._r2_a42_s0_1004）の数字は手元にあるので、

  - 60秒の止まりの数とロボットどうしの数（v8b：57回・51回）
  - 逃がし弁（v8b：あきらめた61・移した54・卓を空けた19）
  - 1往復で止まった秒（平均）、ロボットが運んだ皿の割合
  - detours・robot_yields・detour_extra_m の平均
  - 最後まで回り切るか／来た組が v8b と同じ 64組 か

  を並べて報告し、回り切っていれば Check-in して番号をお伝えします。

  なお .asset 
  が3つ（RobotSpec・StoreLayout_p1・StoreLayout_p5）保留に入っています。pathMode:
  0（=MapPlanner）などの v9 の欄が入ったものなので意図どおりですが、B の前に 
  RobotSpec.asset の pathMode が 0 のままかだけ Inspector
  で確かめていただけると確実です。
```

### 16:31 確かめ B と Check-in（cs:143）の報告（チャットに貼られたもの）

```text
新しい固定版は cs:143 です。（v9 本体が cs:142、NavMesh の掃除が cs:143。保留は
  Mat_MonitorBoard の Private 2件だけ）

  出力フォルダ
     
  20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1559
  path_mode = MapPlanner / map_generation = 8a5b2f6a016a / stopped_early = False

  ⚠ 先に1つ：サイトの版が上がっていました

  13:49 に site_copy_8a5b2f6a016a がプロジェクトへ入っていました（指示書の
  2fa10547a2b7 より新しいもの）。私の最初の確かめ A
  は旧版で回してしまったので、新版でやり直しています。5つの版（summary
  の6店・店の JSON・地図・params）はすべて 8a5b2f6a016a
  でそろっており、0章の突き合わせは通ります。

  確かめ A（人のいない店で・新版 8a5b2f6a016a）

  Unity がプロジェクトを押さえているので、MapPlanCheck.Run() を mono 
  で直接呼びました（Unity のメニューが呼ぶのと同じ関数。spec=null →
  Settings.Default で、RobotSpec.cs の既定と1つずつ同じ値）。

  ┌──────────────────────────────────┬─────────────────────┬─────────────────┐
  │                                  │         ③           │  ⑦ p5_crammed   │
  │                                  │ p1_efficient_corner │                 │
  ├──────────────────────────────────┼─────────────────────┼─────────────────┤
  │ 狭い道を使った卓                 │ 0 卓（サイト 0）✅  │ 24 卓（サイト   │
  │                                  │                     │ 24）✅          │
  ├──────────────────────────────────┼─────────────────────┼─────────────────┤
  │ 道が引けない卓                   │ 0 / 42              │ 0 / 42          │
  ├──────────────────────────────────┼─────────────────────┼─────────────────┤
  │ 長さの比（最小／平均／最大）     │ 0.959 / 1.019 /     │ 0.959 / 1.037 / │
  │                                  │ 1.057               │  1.227          │
  ├──────────────────────────────────┼─────────────────────┼─────────────────┤
  │ 道探しの時間（平均／中央／最大） │ 14.8 / 11.8 / 38.8  │ 33.9 / 25.0 /   │
  │                                  │ ms                  │ 90.3 ms         │
  ├──────────────────────────────────┼─────────────────────┼─────────────────┤
  │ 待機場所3つ                      │ すべて引けた        │ すべて引けた    │
  └──────────────────────────────────┴─────────────────────┴─────────────────┘

  マスの数は JSON の counts と一致（③⑦で free_class の sha256
  は別、マスの数が偶然同一でした）。

  確かめ B（v8b の 1004 と比べて）

  ┌────────────────────────────────┬────────────────┬───────────────┐
  │                                │ v8b SiteRoutes │ v9 MapPlanner │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 60秒の止まり                   │ 57             │ 20            │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ うちロボットどうし             │ 51             │ 15            │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 1往復で止まった秒（平均）      │ 109.85         │ 33.39         │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ ロボットが運んだ皿の割合       │ 0.644          │ 0.994         │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 往復                           │ 72             │ 114           │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 打ち切り                       │ 11             │ 1             │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 動けなくなった                 │ 57             │ 20            │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ あきらめた／移した／卓を空けた │ 61 / 54 / 19   │ 31 / 59 / 2   │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 料理の待ち                     │ 2.90 分        │ 1.17 分       │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 入店待ち                       │ 1.67 分        │ 1.47 分       │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 来た組                         │ 64             │ 64 ✅         │
  ├────────────────────────────────┼────────────────┼───────────────┤
  │ 全員が店を出た                 │ ✅             │ ✅            │
  └────────────────────────────────┴────────────────┴───────────────┘

  v9 の新しい列：detours 平均 0.649／robot_yields 平均 1.412／detour_extra_m 平均
  0.351 m／replans 平均 31.4／plan_ms_max 平均 18.8 ms・最大 58.5 ms。
  stops.csv の解け方：自分で動き出した 1911・よける場所へ動いた 
  95・回り込んで動き出した 63・動けなくなった 20・入口で待った 1。

  60秒止まりの場所も分散しました（v8b は (9.9,4.6)×11・(10.3,4.4)×9 に集中 → v9
  は (14.3,5.3)×7 が最多）。

  v8・v8b の不変条件もすべて通ります（a1・b1・b3・c・d1・d2）。新しい trip_end_s
  で見ると、v8 の確かめで残っていた a2 の超えが 20 → 0 になりました。

  気づいたこと2つ（報告のみ・直していません）

  1. 止まりの総数が増えました（490 → 2090）。 ただし1往復で止まった秒は 109.85 →
  33.39 と3分の1以下です。「長く固まる」から「短く止まって回り込む」へ変わった結
  果で、狙いどおりと見ています。

  2. ③で narrow_used が 8/114 の往復で立ちました。 確かめ
  A（人のいない店）では③は 0 
  卓なので、矛盾ではありません。回しているあいだは3章の回り込み・4章のよけで人や
  もう1台を通れないマスにするため、ふだんの道がふさがると1のマス（0.8 m 相当）へ
  落ちて通っています。指示書の「行けなければ1のマスも使って引く」どおりです。
  ただしサイトは③を「狭い道を使わない店」と見ているので、比べるときに食い違う可能
  性があります。narrowCellCost
  を上げる／ふさがれたら待つ、のどちらにするかは判断をお待ちします。

  取りこぼしを1つ直しました

  cs:142 に ProjectSettings/NavMeshAreas.asset の New Agent 
  6件が混ざっていました（既知の「NavMesh の種類が増え続ける」件）。cs:141
  の内容へ戻して cs:143 で入れ直しています。動きには効きません（PeopleNav
  が走るときに付け替えます）。v9 でも人は NavMesh
  のままなので、この溜まり方は前と同じです。

  段階Bの18回の回し直しは、この結果を見てからのご指示をお待ちします。
```



## 確かめ A の出力（unity/screens/）

### screens/20261024_1150_v9_checkA_map_planner.txt

```text
# 確かめ A：人のいない店で、受け渡し口から全卓へ道を引く（依頼 v9 の 7 章 A）
回した所: Unity の外（mono）。
  Unity Editor がプロジェクトを押さえているので、Assets の
  MapPlanCheck.cs / GridPlanner.cs / SimpleJson.cs をそのまま組み立てて回した。
  Unity のメニュー ServiceSim/Map Planner が呼ぶのと同じ MapPlanCheck.Run。
回した日時: 2026-10-24 11:55:20
道の引き方は RobotSpec.asset と同じ既定の値（narrowCellCost 3 / centerWeight 2 /
  preferClearM 0.6 / maxExpand 200000 / snapM 1 / simplifyMaxM 6）

## ③ 効率重視型・入口が左下の角（p1_efficient_corner）
  地図 (map.json): 版 2fa10547a2b7 / マス 960 × 640 = 614400 / 1 マス 0.025 m
    ふだんの道の幅 1 m / 通れる幅 0.8 m
    マスの数: 2（ふだんの道）148223 / 1（通れる幅だけ）30397 / 0（通れない）435780  … JSON の counts と 一致
  道の引き方: narrowCellCost 3 / centerWeight 2 / preferClearM 0.6 / maxExpand 200000 / snapM 1 / simplifyMaxM 6
  受け渡し口の前 (15.3, 5.95) → マスの種類 2
  狭い道を使った卓: 引いた道 0 卓 / サイトの routes の use 0 卓  → 一致
  道が引けない卓: 0 / 42
  長さの比（引いた道 ÷ サイトの length）: 最小 0.959 / 平均 1.019 / 最大 1.057（真ん中寄りにした分だけ延びる）
  道探しの時間: 平均 13.177 ms / 中央 11.113 ms / 最大 35.921 ms
  出発点・行き先が地図で通れないマス（0）だったので寄せた卓: 0 / 42
  待機場所1 (16.9, 6.05) マスの種類 1 → 引けた（1.605 m・0.508 ms・狭い道を使った）
  待機場所2 (17.7, 6.05) マスの種類 1 → 引けた（2.403 m・0.822 ms・狭い道を使った）
  待機場所3 (18.5, 6.05) マスの種類 1 → 引けた（3.202 m・1.064 ms・狭い道を使った）
  → 確かめ A は通った
  CSV: /private/tmp/claude-501/-Users-shimazueiichi/c8678a42-0b89-4f0f-8750-cfda10b0d91e/scratchpad/harness/checkA_p1_efficient_corner.csv

## ⑦ ぎっしり型（p5_crammed）
  地図 (map.json): 版 2fa10547a2b7 / マス 960 × 640 = 614400 / 1 マス 0.025 m
    ふだんの道の幅 1 m / 通れる幅 0.8 m
    マスの数: 2（ふだんの道）148223 / 1（通れる幅だけ）30397 / 0（通れない）435780  … JSON の counts と 一致
  道の引き方: narrowCellCost 3 / centerWeight 2 / preferClearM 0.6 / maxExpand 200000 / snapM 1 / simplifyMaxM 6
  受け渡し口の前 (15.3, 5.95) → マスの種類 2
  狭い道を使った卓: 引いた道 24 卓 / サイトの routes の use 24 卓  → 一致
  道が引けない卓: 0 / 42
  長さの比（引いた道 ÷ サイトの length）: 最小 0.959 / 平均 1.037 / 最大 1.227（真ん中寄りにした分だけ延びる）
  道探しの時間: 平均 30.359 ms / 中央 23.372 ms / 最大 80.214 ms
  出発点・行き先が地図で通れないマス（0）だったので寄せた卓: 0 / 42
  待機場所1 (16.9, 6.05) マスの種類 1 → 引けた（1.605 m・0.502 ms・狭い道を使った）
  待機場所2 (17.7, 6.05) マスの種類 1 → 引けた（2.403 m・0.817 ms・狭い道を使った）
  待機場所3 (18.5, 6.05) マスの種類 1 → 引けた（3.202 m・1.124 ms・狭い道を使った）
  → 確かめ A は通った
  CSV: /private/tmp/claude-501/-Users-shimazueiichi/c8678a42-0b89-4f0f-8750-cfda10b0d91e/scratchpad/harness/checkA_p5_crammed.csv

→ ③⑦ ともに確かめ A は通った
```

### screens/20261024_1150_v9_planner_unit_checks.txt

```text
行き先 T20 (9.312, 8.662) マスの種類 2
  ○ 1. 同じ条件なら毎回同じ道 … 点 9 個・10.825 m を 2 回引いて 同じ
  ○ 2. 人を通れないマスにすると道が変わる … 結果 引けた / 長さ 10.825 m → 11.697 m
  ○ 3. 重いマスを差し込んでも引ける … 結果 引けた / 長さ 16.145 m（もとは 10.825 m）
  ○ 4. 行き先をふさぐと引けない … 結果 つながっていない
   出発点 (10, 4.5) マスの種類 0（0 なら地図では通れない所）
  ○ 5. 出発点が 0 のマスでも寄せて引ける … 結果 引けた（狭い道） / 寄せた はい 0.088 m / 長さ 4.917 m
  ○ 6. 曲がり角の点がすべて通れるマスの上 … はみ出し 0 点
  ○ 7. 曲がり角だけにしても長さが暴れない … マスをたどった 11.122 m → 縮めた 10.825 m

→ ぜんぶ通った
```

### screens/20261024_1150_v9_map_plan_p1_efficient_corner.csv

```csv
table,site_use,plan_use,site_length_m,plan_length_m,ratio,corners,plan_ms,status
T01,normal,normal,14.114,14.284,1.012,10,12.644,ok
T02,normal,normal,15.961,16.176,1.013,10,27.164,ok
T03,normal,normal,17.81,18.026,1.012,10,31.586,ok
T04,normal,normal,19.664,19.875,1.011,10,35.034,ok
T05,normal,normal,14.31,14.531,1.015,10,13.03,ok
T06,normal,normal,16.16,16.376,1.013,10,27.927,ok
T07,normal,normal,18.014,18.225,1.012,10,31.409,ok
T08,normal,normal,19.861,20.075,1.011,10,35.921,ok
T09,normal,normal,10.61,10.831,1.021,10,10.373,ok
T10,normal,normal,12.464,12.676,1.017,10,18.678,ok
T11,normal,normal,14.31,14.525,1.015,10,21.566,ok
T12,normal,normal,16.161,16.375,1.013,10,25.509,ok
T13,normal,normal,10.61,10.831,1.021,10,10.106,ok
T14,normal,normal,12.464,12.676,1.017,10,19.638,ok
T15,normal,normal,14.31,14.525,1.015,10,21.039,ok
T16,normal,normal,16.161,16.375,1.013,10,25.851,ok
T17,normal,normal,16.277,16.872,1.037,23,22.54,ok
T18,normal,normal,6.915,7.131,1.031,9,7.324,ok
T19,normal,normal,8.761,8.976,1.025,9,11.182,ok
T20,normal,normal,10.611,10.825,1.02,9,13.553,ok
T21,normal,normal,12.139,12.7,1.046,25,11.046,ok
T22,normal,normal,11.576,11.988,1.036,22,7.262,ok
T23,normal,normal,6.915,7.131,1.031,9,7.335,ok
T24,normal,normal,8.761,8.976,1.025,9,11.113,ok
T25,normal,normal,10.611,10.825,1.02,9,13.286,ok
T26,normal,normal,12.139,12.7,1.046,25,10.816,ok
T27,normal,normal,2.212,2.235,1.01,3,0.127,ok
T28,normal,normal,2.584,2.714,1.05,7,0.439,ok
T29,normal,normal,4.315,4.56,1.057,7,1.015,ok
T30,normal,normal,6.149,6.398,1.04,8,1.422,ok
T31,normal,normal,7.609,7.711,1.013,3,6.19,ok
T32,normal,normal,5.994,5.998,1.001,2,3.896,ok
T33,normal,normal,4.581,4.581,1,2,0.583,ok
T34,normal,normal,3.763,3.751,0.997,2,0.321,ok
T35,normal,normal,3.804,3.79,0.996,2,1.195,ok
T36,normal,normal,4.291,4.386,1.022,7,4.116,ok
T37,normal,normal,5.371,5.485,1.021,5,6.36,ok
T38,normal,normal,6.468,6.585,1.018,5,7.361,ok
T39,normal,normal,7.568,7.685,1.015,5,10.418,ok
T40,normal,normal,8.667,8.794,1.015,6,11.907,ok
T41,normal,normal,9.767,9.894,1.013,6,12.977,ok
T42,normal,normal,5.379,5.159,0.959,2,2.187,ok
```

### screens/20261024_1150_v9_map_plan_p5_crammed.csv

```csv
table,site_use,plan_use,site_length_m,plan_length_m,ratio,corners,plan_ms,status
T01,narrow,narrow,14.066,14.379,1.022,9,23.372,ok
T02,narrow,narrow,15.917,16.229,1.02,9,50.99,ok
T03,narrow,narrow,17.766,18.079,1.018,9,78.034,ok
T04,narrow,narrow,19.616,22.376,1.141,31,80.214,ok
T05,narrow,narrow,14.266,14.579,1.022,9,30.275,ok
T06,narrow,narrow,16.116,16.429,1.019,9,54.934,ok
T07,narrow,narrow,17.966,18.279,1.017,9,78.396,ok
T08,narrow,narrow,19.817,22.158,1.118,30,79.807,ok
T09,narrow,narrow,10.815,11.137,1.03,8,24.795,ok
T10,narrow,narrow,12.666,12.987,1.025,8,46.28,ok
T11,narrow,narrow,14.516,14.837,1.022,8,68.644,ok
T12,narrow,narrow,16.367,20.082,1.227,28,72.066,ok
T13,narrow,narrow,10.815,11.137,1.03,8,24.696,ok
T14,narrow,narrow,12.666,12.987,1.025,8,47.216,ok
T15,narrow,narrow,14.516,14.837,1.022,8,69.505,ok
T16,narrow,narrow,16.367,20.082,1.227,28,71.806,ok
T17,normal,normal,16.199,16.718,1.032,24,26.189,ok
T18,narrow,narrow,7.365,7.687,1.044,9,17.502,ok
T19,narrow,narrow,9.215,9.537,1.035,9,36.312,ok
T20,narrow,narrow,11.066,11.387,1.029,9,55.642,ok
T21,narrow,narrow,11.827,12.64,1.069,21,21.916,ok
T22,normal,normal,11.463,11.984,1.045,20,11.278,ok
T23,narrow,narrow,7.365,7.687,1.044,9,17.235,ok
T24,narrow,narrow,9.215,9.537,1.035,9,37.039,ok
T25,narrow,narrow,11.066,11.387,1.029,9,55.772,ok
T26,narrow,narrow,11.827,12.64,1.069,21,22.423,ok
T27,normal,normal,2.962,2.975,1.004,2,0.163,ok
T28,normal,normal,3.275,3.23,0.986,3,0.299,ok
T29,normal,normal,4.557,4.705,1.032,7,1.258,ok
T30,normal,normal,6.291,6.599,1.049,8,2.593,ok
T31,normal,normal,7.596,7.62,1.003,3,7.306,ok
T32,normal,normal,5.994,5.998,1.001,2,4.068,ok
T33,normal,normal,4.581,4.581,1,2,0.58,ok
T34,normal,normal,3.763,3.751,0.997,2,0.343,ok
T35,normal,normal,3.804,3.79,0.996,2,1.208,ok
T36,normal,normal,4.291,4.386,1.022,7,4.118,ok
T37,normal,normal,5.371,5.485,1.021,5,6.041,ok
T38,normal,normal,6.468,6.585,1.018,5,7.774,ok
T39,normal,normal,7.568,7.685,1.015,5,10.109,ok
T40,normal,normal,8.667,8.794,1.015,6,11.739,ok
T41,normal,normal,9.767,9.894,1.013,6,12.952,ok
T42,normal,normal,5.379,5.159,0.959,2,2.173,ok
```



## Unity のコード（cs:143）の全文

新しく作った4つと、直した9つのファイルです。パスは /Users/shimazueiichi/Robotics_UX/Robotics_UX_New/ から。

### Assets/ServiceSim/Robot/GridPlanner.cs

```csharp
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットが走れる所の地図（サイトの {名前}_map.json）。
    ///
    /// 2026-10-24 にエイイチと決めたこと（依頼 v9）:
    ///   ロボットは**動ける範囲を地図で持ち、道はその都度自分で引く**。
    ///   地図はサイトが正（版 2fa10547a2b7 で公開）。Unity は塗り直さない・通れる所を足さない。
    ///   NavMesh はロボットの道探しには使わない（人に自分をよけてもらうためだけに残す）。
    ///
    /// マスの意味（JSON の free_class_values そのまま）:
    ///   2 … ふだん走らせる幅の道（normal_width 1.0 m）が通れる
    ///   1 … 通れる幅の道だけ（pass_width 0.8 m）通れる
    ///   0 … 通れない
    /// clear_mm … そのマスから、いちばん近い障害物までの距離（mm）
    ///
    /// 大事な読み方: free_class は「**ロボットの中心を置ける所**」で、
    /// 道の幅のぶんをもう削ってある（体の半径 0.25 m ではなく、道の幅の半分で削ってある）。
    /// だから 1.2 m の通路でも、中心を置ける帯は 0.45 m しかない。
    /// 2 台（中心どうし 0.5 m）は通路の中では並べない。
    ///
    /// 座標: マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res。
    ///       JSON の x → Unity の X、y → Unity の Z。番号は iy*nx+ix。
    /// </summary>
    public class RobotMap
    {
        public readonly string generation = "";
        public readonly float res;
        public readonly int nx, ny;
        public readonly float normalWidthM, passWidthM;

        readonly byte[] _class;
        readonly ushort[] _clearMm;

        /// <summary>JSON が持っている counts（読んだものと突き合わせる）</summary>
        readonly int[] _jsonCounts = new int[3];
        readonly int[] _realCounts = new int[3];

        /// <summary>つながりの番号。2 だけでたどれる組と、1 以上でたどれる組</summary>
        int[] _region2, _region1;

        GridPlanner _planner;

        RobotMap(string generation, float res, int nx, int ny,
                 float normalWidthM, float passWidthM,
                 byte[] cls, ushort[] clearMm, int[] jsonCounts)
        {
            this.generation = generation ?? "";
            this.res = res;
            this.nx = nx;
            this.ny = ny;
            this.normalWidthM = normalWidthM;
            this.passWidthM = passWidthM;
            _class = cls;
            _clearMm = clearMm;
            for (int i = 0; i < 3; i++) _jsonCounts[i] = jsonCounts[i];
            for (int i = 0; i < cls.Length; i++)
            {
                int c = cls[i];
                if (c >= 0 && c <= 2) _realCounts[c]++;
            }
        }

        public int CellCount { get { return nx * ny; } }

        /// <summary>この地図を使う道探し（1つを使い回す。配列が大きいので台ごとに作らない）</summary>
        public GridPlanner Planner
        {
            get
            {
                if (_planner == null) _planner = new GridPlanner(this);
                return _planner;
            }
        }

        // ---------------- 読む ----------------

        /// <summary>
        /// 地図の JSON を読む。読めなければ error に理由を入れて null を返す。
        /// free_class・clear_mm は base64 → gzip を解く（JSON の encoding のとおり）。
        /// </summary>
        public static RobotMap Read(string json, out string error)
        {
            error = null;
            JsonValue root = JsonValue.Parse(json, out error);
            if (root == null || !root.IsObject)
            {
                error = "地図の JSON を読めません: " + error;
                return null;
            }

            float res = root["res"].F(0f);
            int nx = root["nx"].I(0);
            int ny = root["ny"].I(0);
            if (res <= 0.0001f || nx <= 0 || ny <= 0)
            {
                error = "地図の res / nx / ny がありません";
                return null;
            }

            int want = nx * ny;
            byte[] cls = Unpack(root["free_class"].S(""), 1, want, "free_class", out error);
            if (cls == null) return null;
            byte[] raw = Unpack(root["clear_mm"].S(""), 2, want, "clear_mm", out error);
            if (raw == null) return null;

            ushort[] clear = new ushort[want];
            for (int i = 0; i < want; i++)
                clear[i] = (ushort)(raw[i * 2] | (raw[i * 2 + 1] << 8));   // 小さい桁が先

            int[] counts = new int[3];
            JsonValue cj = root["counts"];
            for (int c = 0; c <= 2; c++)
                counts[c] = cj[c.ToString(CultureInfo.InvariantCulture)].I(-1);

            return new RobotMap(root["meta"]["generation"].S(""), res, nx, ny,
                                root["normal_width"].F(0f), root["pass_width"].F(0f),
                                cls, clear, counts);
        }

        static byte[] Unpack(string b64, int bytesPerCell, int cells, string what, out string error)
        {
            error = null;
            if (string.IsNullOrEmpty(b64))
            {
                error = what + " がありません";
                return null;
            }
            byte[] packed;
            try { packed = Convert.FromBase64String(b64); }
            catch (Exception e)
            {
                error = what + " の base64 を解けません: " + e.Message;
                return null;
            }

            byte[] data;
            try
            {
                using (MemoryStream src = new MemoryStream(packed))
                using (GZipStream gz = new GZipStream(src, CompressionMode.Decompress))
                using (MemoryStream dst = new MemoryStream(cells * bytesPerCell))
                {
                    byte[] buf = new byte[1 << 16];
                    int n;
                    while ((n = gz.Read(buf, 0, buf.Length)) > 0) dst.Write(buf, 0, n);
                    data = dst.ToArray();
                }
            }
            catch (Exception e)
            {
                error = what + " の gzip を解けません: " + e.Message;
                return null;
            }

            if (data.Length != cells * bytesPerCell)
            {
                error = what + " の長さが合いません: " + data.Length + " バイト / 要るのは " +
                        (cells * bytesPerCell) + " バイト（nx × ny × " + bytesPerCell + "）";
                return null;
            }
            return data;
        }

        // ---------------- マスを引く ----------------

        public int Index(int ix, int iy) { return iy * nx + ix; }

        /// <summary>その点のマスの番号。店の外なら -1</summary>
        public int CellAt(Vector3 p)
        {
            int ix = Mathf.FloorToInt(p.x / res);
            int iy = Mathf.FloorToInt(p.z / res);
            if (ix < 0 || ix >= nx || iy < 0 || iy >= ny) return -1;
            return iy * nx + ix;
        }

        /// <summary>マスの真ん中（Unity の XZ。y は 0）</summary>
        public Vector3 Center(int index)
        {
            int iy = index / nx;
            int ix = index - iy * nx;
            return new Vector3((ix + 0.5f) * res, 0f, (iy + 0.5f) * res);
        }

        /// <summary>そのマスの種類（2 / 1 / 0）。店の外は 0</summary>
        public int ClassOf(int index)
        {
            return index < 0 || index >= _class.Length ? 0 : _class[index];
        }

        /// <summary>その点の種類（店の外は 0）</summary>
        public int ClassAt(Vector3 p) { return ClassOf(CellAt(p)); }

        /// <summary>いちばん近い障害物までの距離（m）。店の外は 0</summary>
        public float ClearM(int index)
        {
            return index < 0 || index >= _clearMm.Length ? 0f : _clearMm[index] * 0.001f;
        }

        /// <summary>読んだマスの数（種類ごと）</summary>
        public int CountOf(int cls) { return cls >= 0 && cls <= 2 ? _realCounts[cls] : 0; }

        /// <summary>JSON の counts と読んだ数が合っているか</summary>
        public bool CountsMatch
        {
            get
            {
                for (int c = 0; c <= 2; c++)
                    if (_jsonCounts[c] != _realCounts[c]) return false;
                return true;
            }
        }

        /// <summary>
        /// 斜めに進めるか（両隣が通れるときだけ。サイトの道の引き方と同じ）。
        /// ix,iy → jx,jy は斜め隣のこと
        /// </summary>
        public bool DiagonalOpen(int ix, int iy, int jx, int jy, int minClass)
        {
            return _class[iy * nx + jx] >= minClass && _class[jy * nx + ix] >= minClass;
        }

        // ---------------- つながり（2 だけで行けるかを一瞬で見る）----------------

        /// <summary>
        /// 「行き先まで 2 のマスだけで行けるか」を、毎回 A* を空振りさせずに見るための組分け。
        /// 道の引き方（8方向・斜めは両隣が通れるときだけ）とまったく同じつなぎ方で塗る。
        /// </summary>
        public void BuildRegions()
        {
            if (_region2 != null) return;
            _region2 = Flood(2);
            _region1 = Flood(1);
        }

        int[] Flood(int minClass)
        {
            int n = nx * ny;
            int[] region = new int[n];
            for (int i = 0; i < n; i++) region[i] = -1;
            int[] stack = new int[n];
            int next = 0;

            for (int s = 0; s < n; s++)
            {
                if (region[s] >= 0 || _class[s] < minClass) continue;
                int id = next++;
                int top = 0;
                stack[top++] = s;
                region[s] = id;
                while (top > 0)
                {
                    int i = stack[--top];
                    int iy = i / nx;
                    int ix = i - iy * nx;
                    for (int d = 0; d < 8; d++)
                    {
                        int jx = ix + GridPlanner.Dx[d];
                        int jy = iy + GridPlanner.Dy[d];
                        if (jx < 0 || jx >= nx || jy < 0 || jy >= ny) continue;
                        int j = jy * nx + jx;
                        if (region[j] >= 0 || _class[j] < minClass) continue;
                        if (GridPlanner.Dx[d] != 0 && GridPlanner.Dy[d] != 0 &&
                            !DiagonalOpen(ix, iy, jx, jy, minClass)) continue;
                        region[j] = id;
                        stack[top++] = j;
                    }
                }
            }
            return region;
        }

        /// <summary>2 つのマスが、その種類だけでつながっているか</summary>
        public bool SameRegion(int a, int b, int minClass)
        {
            BuildRegions();
            int[] r = minClass >= 2 ? _region2 : _region1;
            if (a < 0 || b < 0 || a >= r.Length || b >= r.Length) return false;
            return r[a] >= 0 && r[a] == r[b];
        }

        // ---------------- 通れるマスへ寄せる ----------------

        /// <summary>
        /// その点のいちばん近い「通れるマス」。
        ///
        /// なぜ要るか: ロボットがいま立っている所が、地図では 0（通れない）のことがある。
        /// NavMesh は体の半径 0.25 m で焼いてあるのに、free_class は道の幅（0.4 m／0.5 m）で
        /// 削ってあるので、通路の端・通路の入口で待つ点・横へどいた点は 0 になる。
        /// （v8b の 20261024_1004 の回では、60 秒の止まり 51 回のうち 36 回がそういう所だった）
        /// 地図は塗り直さず、**出発点だけを近くの通れるマスへ寄せる**。寄せた回数は記録に出す。
        ///
        /// 見つからなければ -1。
        /// </summary>
        public int NearestPassable(Vector3 p, int minClass, float searchM)
        {
            int here = CellAt(p);
            if (here >= 0 && _class[here] >= minClass) return here;

            int ix0 = Mathf.FloorToInt(p.x / res);
            int iy0 = Mathf.FloorToInt(p.z / res);
            int maxR = Mathf.Max(1, Mathf.CeilToInt(searchM / res));

            int best = -1;
            float bestD2 = float.MaxValue;
            for (int r = 1; r <= maxR; r++)
            {
                for (int iy = iy0 - r; iy <= iy0 + r; iy++)
                {
                    if (iy < 0 || iy >= ny) continue;
                    for (int ix = ix0 - r; ix <= ix0 + r; ix++)
                    {
                        if (ix < 0 || ix >= nx) continue;
                        // 輪の上だけ見る（中は前の回で見た）
                        if (Mathf.Abs(ix - ix0) != r && Mathf.Abs(iy - iy0) != r) continue;
                        int i = iy * nx + ix;
                        if (_class[i] < minClass) continue;
                        Vector3 c = Center(i);
                        float dx = c.x - p.x, dz = c.z - p.z;
                        float d2 = dx * dx + dz * dz;
                        // 同じ距離なら番号の小さい方（毎回同じ答えにする）
                        if (d2 < bestD2 - 1e-9f) { bestD2 = d2; best = i; }
                    }
                }
                if (best >= 0) return best;      // 輪を1つ見終わって見つかれば、それが最短
            }
            return best;
        }

        // ---------------- 報告 ----------------

        public string Report()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("  地図 (map.json): 版 " + generation +
                          " / マス " + nx + " × " + ny + " = " + CellCount +
                          " / 1 マス " + F(res) + " m");
            sb.AppendLine("    ふだんの道の幅 " + F(normalWidthM) + " m / 通れる幅 " + F(passWidthM) + " m");
            sb.AppendLine("    マスの数: 2（ふだんの道）" + _realCounts[2] +
                          " / 1（通れる幅だけ）" + _realCounts[1] +
                          " / 0（通れない）" + _realCounts[0] +
                          "  … JSON の counts と " + (CountsMatch ? "一致" : "ちがう") +
                          (CountsMatch ? "" : "（JSON: 2=" + _jsonCounts[2] + " 1=" + _jsonCounts[1] +
                                              " 0=" + _jsonCounts[0] + "）"));
            return sb.ToString().TrimEnd();
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
    }

    /// <summary>
    /// 地図の上で道を引く（依頼 v9 の 2 章）。ロボットだけが使う。NavMesh は使わない。
    ///
    /// 決まり:
    ///   ・8 方向。斜めは両隣が通れるときだけ（サイトと同じ）
    ///   ・行き先まで 2 のマスだけで行けるなら 2 だけで引く。行けなければ 1 も使い、
    ///     1 のマスを narrowCellCost 倍（目安 3 倍）重くする
    ///   ・通路の真ん中寄りを走る: 1 歩の重さに
    ///     centerWeight × max(0, preferClearM − clear) / preferClearM を足す
    ///   ・乱数を使わない。同じ条件なら毎回同じ道（同じ重さのときは h → マスの番号の順）
    ///   ・引けた道は曲がり角の点だけに縮める。近道は「もとの道で通っていた所より
    ///     障害物に近づかない所」だけ（端に寄った線に戻さないため）
    ///
    /// 回り込み・よけのために、その 1 回だけ「通れないマス」「重いマス」を差し込める。
    /// 配列が大きいので、1 つを台どうしで使い回す（道探しはその場で終わるので取り合わない）。
    /// </summary>
    public class GridPlanner
    {
        /// <summary>8 方向の順（決まった順。乱数を使わない）</summary>
        public static readonly int[] Dx = { 1, 1, 0, -1, -1, -1, 0, 1 };
        public static readonly int[] Dy = { 0, 1, 1, 1, 0, -1, -1, -1 };

        /// <summary>道探しの結果</summary>
        public enum Status
        {
            Ok,             // 引けた
            StartBlocked,   // 出発点が通れないマス（寄せても見つからない）
            GoalBlocked,    // 行き先が通れないマス
            NoPath,         // つながっていない
            TooMany         // 探しすぎ（maxExpand を超えた）
        }

        /// <summary>道の引き方の数字（RobotSpec から写す）</summary>
        public struct Settings
        {
            public float narrowCellCost;
            public float centerWeight;
            public float preferClearM;
            public int maxExpand;
            public float snapM;

            /// <summary>曲がり角だけに縮めるとき、1 本の近道の長さの上限（m）</summary>
            public float simplifyMaxM;

            public static Settings Default
            {
                get
                {
                    Settings s = new Settings();
                    s.narrowCellCost = 3f;
                    s.centerWeight = 2f;
                    s.preferClearM = 0.6f;
                    s.maxExpand = 200000;
                    s.snapM = 1.0f;
                    s.simplifyMaxM = 6f;
                    return s;
                }
            }
        }

        readonly RobotMap _map;
        readonly int _nx, _ny, _n;
        readonly float _res;

        // ---- 道探しの作業場（毎回作り直さない）----
        readonly float[] _g;
        readonly int[] _from;
        readonly int[] _seen;          // この回で見たか（_runStamp と比べる）
        readonly int[] _closed;
        int _runStamp;

        // ---- 差し込んだ「通れないマス」「重いマス」----
        readonly int[] _blockStamp;
        readonly int[] _costStamp;
        readonly float[] _costAdd;
        int _markStamp;
        bool _hasMarks;

        // ---- 二分の山（f が小さい順。同じなら h → 番号）----
        int[] _heap;
        float[] _heapF, _heapH;
        int _heapCount;

        readonly List<Vector3> _points = new List<Vector3>();
        readonly List<int> _cells = new List<int>();
        readonly Stopwatch _watch = new Stopwatch();

        public GridPlanner(RobotMap map)
        {
            _map = map;
            _nx = map.nx; _ny = map.ny; _n = map.nx * map.ny; _res = map.res;
            _g = new float[_n];
            _from = new int[_n];
            _seen = new int[_n];
            _closed = new int[_n];
            _blockStamp = new int[_n];
            _costStamp = new int[_n];
            _costAdd = new float[_n];
            _heap = new int[1024];
            _heapF = new float[1024];
            _heapH = new float[1024];
            // 印は 1 から始める。0 のままだと、何も印をつけていないマス（0）が
            // 「印がついている」に見えてしまう
            _markStamp = 1;
            map.BuildRegions();
        }

        public RobotMap Map { get { return _map; } }

        // ---- 直前の結果 ----
        public Status LastStatus { get; private set; }

        /// <summary>引けた道の曲がり角の点（最初は出発のマスの真ん中、最後は行き先のマスの真ん中）</summary>
        public IReadOnlyList<Vector3> Points { get { return _points; } }

        /// <summary>道に 1 のマスが入ったか（＝狭い道を使った）</summary>
        public bool UsedNarrow { get; private set; }

        /// <summary>この 1 回にかかった時間（ミリ秒）</summary>
        public float LastPlanMs { get; private set; }

        /// <summary>広げたマスの数（重さを調べるため）</summary>
        public int LastExpanded { get; private set; }

        /// <summary>マスをたどった長さ（縮める前, m）</summary>
        public float LastRawLengthM { get; private set; }

        /// <summary>縮めたあとの長さ（m）</summary>
        public float LastLengthM { get; private set; }

        /// <summary>出発のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastStartSnapM { get; private set; }

        /// <summary>行き先のマスの真ん中と、渡された点のあいだの距離（m）</summary>
        public float LastGoalSnapM { get; private set; }

        /// <summary>
        /// 出発点が、地図では通れないマス（0）だったか。
        /// ロボットは NavMesh（体の半径 0.25 m）で焼いた面を歩くので、
        /// 通路の端・通路の入口で待つ点・横へどいた点は free_class では 0 になる。
        /// そういうときは近くの通れるマスへ寄せて引く。何回あったかを記録に出す
        /// </summary>
        public bool LastStartWasBlocked { get; private set; }

        /// <summary>行き先が、地図では通れないマス（0）だったか</summary>
        public bool LastGoalWasBlocked { get; private set; }

        public string StatusText
        {
            get
            {
                switch (LastStatus)
                {
                    case Status.Ok: return UsedNarrow ? "引けた（狭い道）" : "引けた";
                    case Status.StartBlocked: return "出発点が通れない";
                    case Status.GoalBlocked: return "行き先が通れない";
                    case Status.NoPath: return "つながっていない";
                    default: return "探しすぎ";
                }
            }
        }

        // ---------------- 差し込み（その 1 回だけ）----------------

        /// <summary>差し込んだものをぜんぶ消す（道を引く前に必ず呼ぶ）</summary>
        public void ClearMarks()
        {
            _markStamp++;
            _hasMarks = false;
        }

        /// <summary>丸い所を「通れないマス」にする（人・ほかのロボットの体）</summary>
        public void BlockCircle(Vector3 center, float radiusM)
        {
            MarkCapsule(center, center, radiusM, true, 0f);
        }

        /// <summary>線のまわりを「通れないマス」にする</summary>
        public void BlockLine(Vector3 a, Vector3 b, float radiusM)
        {
            MarkCapsule(a, b, radiusM, true, 0f);
        }

        /// <summary>線のまわりを重くする（相手の先の道から離れて走らせる）</summary>
        public void CostLine(Vector3 a, Vector3 b, float radiusM, float add)
        {
            MarkCapsule(a, b, radiusM, false, add);
        }

        void MarkCapsule(Vector3 a, Vector3 b, float radiusM, bool block, float add)
        {
            if (radiusM <= 0f) return;
            _hasMarks = true;

            float minX = Mathf.Min(a.x, b.x) - radiusM, maxX = Mathf.Max(a.x, b.x) + radiusM;
            float minZ = Mathf.Min(a.z, b.z) - radiusM, maxZ = Mathf.Max(a.z, b.z) + radiusM;
            int ix0 = Mathf.Max(0, Mathf.FloorToInt(minX / _res));
            int ix1 = Mathf.Min(_nx - 1, Mathf.FloorToInt(maxX / _res));
            int iy0 = Mathf.Max(0, Mathf.FloorToInt(minZ / _res));
            int iy1 = Mathf.Min(_ny - 1, Mathf.FloorToInt(maxZ / _res));
            float r2 = radiusM * radiusM;

            for (int iy = iy0; iy <= iy1; iy++)
            {
                for (int ix = ix0; ix <= ix1; ix++)
                {
                    float cx = (ix + 0.5f) * _res, cz = (iy + 0.5f) * _res;
                    if (SegDist2(cx, cz, a.x, a.z, b.x, b.z) > r2) continue;
                    int i = iy * _nx + ix;
                    if (block)
                    {
                        _blockStamp[i] = _markStamp;
                        continue;
                    }
                    if (_costStamp[i] == _markStamp) _costAdd[i] = Mathf.Max(_costAdd[i], add);
                    else { _costStamp[i] = _markStamp; _costAdd[i] = add; }
                }
            }
        }

        static float SegDist2(float px, float pz, float ax, float az, float bx, float bz)
        {
            float vx = bx - ax, vz = bz - az;
            float l2 = vx * vx + vz * vz;
            float t = 0f;
            if (l2 > 1e-12f)
            {
                t = ((px - ax) * vx + (pz - az) * vz) / l2;
                t = t < 0f ? 0f : (t > 1f ? 1f : t);
            }
            float dx = px - (ax + t * vx), dz = pz - (az + t * vz);
            return dx * dx + dz * dz;
        }

        // ---------------- 道を引く ----------------

        /// <summary>
        /// from から to まで道を引く。引けたら Status.Ok で、Points に曲がり角の点が入る。
        ///
        /// 「2 のマスだけで行けるなら 2 だけ」は、つながりの組（RobotMap.SameRegion）で
        /// 一瞬で見る。A* を空振りさせない（⑦では卓の半分が狭い道なので、空振りが重い）。
        /// </summary>
        public Status Plan(Vector3 from, Vector3 to, Settings st)
        {
            _watch.Reset();
            _watch.Start();

            _points.Clear();
            _cells.Clear();
            UsedNarrow = false;
            LastExpanded = 0;
            LastRawLengthM = 0f;
            LastLengthM = 0f;
            LastStartSnapM = 0f;
            LastGoalSnapM = 0f;
            LastStartWasBlocked = _map.ClassAt(from) < 1;
            LastGoalWasBlocked = _map.ClassAt(to) < 1;

            // 行き先は、まず 1 以上のマスに乗っているかを見る（サイトが通れる所に置いている）
            int goal = _map.NearestPassable(to, 1, st.snapM);
            if (goal < 0)
            {
                LastStatus = Status.GoalBlocked;
                return Done();
            }
            LastGoalSnapM = Flat(_map.Center(goal) - to).magnitude;

            int start = _map.NearestPassable(from, 1, st.snapM);
            if (start < 0)
            {
                LastStatus = Status.StartBlocked;
                return Done();
            }
            LastStartSnapM = Flat(_map.Center(start) - from).magnitude;

            // 2 のマスだけで行けるか（出発点・行き先がどちらも 2 で、同じ組）
            int minClass = 1;
            if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                _map.SameRegion(start, goal, 2) && !_hasMarks)
            {
                minClass = 2;
            }
            else if (_map.ClassOf(start) >= 2 && _map.ClassOf(goal) >= 2 &&
                     _map.SameRegion(start, goal, 2))
            {
                // 差し込み（回り込み・よけ）があるときは、つながりの組では決められない。
                // まず 2 だけで試し、だめなら 1 も使う
                if (Search(start, goal, 2, st) == Status.Ok)
                {
                    Finish(st, 2);
                    LastStatus = Status.Ok;
                    return Done();
                }
            }

            if (minClass == 1 && !_map.SameRegion(start, goal, 1))
            {
                LastStatus = Status.NoPath;
                return Done();
            }

            Status s = Search(start, goal, minClass, st);
            if (s != Status.Ok)
            {
                LastStatus = s;
                return Done();
            }
            Finish(st, minClass);
            LastStatus = Status.Ok;
            return Done();
        }

        Status Done()
        {
            _watch.Stop();
            LastPlanMs = (float)(_watch.Elapsed.TotalMilliseconds);
            return LastStatus;
        }

        Status Search(int start, int goal, int minClass, Settings st)
        {
            _runStamp++;
            _heapCount = 0;
            _cells.Clear();

            if (Blocked(start, minClass) || Blocked(goal, minClass)) return Status.NoPath;

            int gy = goal / _nx, gx = goal - gy * _nx;
            float prefMm = Mathf.Max(0.001f, st.preferClearM);
            float d2 = _res * 1.41421356f;
            int expanded = 0;

            _g[start] = 0f;
            _from[start] = -1;
            _seen[start] = _runStamp;
            Push(start, Heuristic(start, gx, gy, d2), Heuristic(start, gx, gy, d2));

            while (_heapCount > 0)
            {
                int i = Pop();
                if (_closed[i] == _runStamp) continue;
                _closed[i] = _runStamp;
                expanded++;
                if (i == goal)
                {
                    LastExpanded = expanded;
                    Trace(start, goal);
                    return Status.Ok;
                }
                if (expanded > st.maxExpand)
                {
                    LastExpanded = expanded;
                    return Status.TooMany;
                }

                int iy = i / _nx, ix = i - iy * _nx;
                float gi = _g[i];

                for (int d = 0; d < 8; d++)
                {
                    int jx = ix + Dx[d], jy = iy + Dy[d];
                    if (jx < 0 || jx >= _nx || jy < 0 || jy >= _ny) continue;
                    int j = jy * _nx + jx;
                    if (_closed[j] == _runStamp) continue;
                    if (Blocked(j, minClass)) continue;

                    float step;
                    if (Dx[d] != 0 && Dy[d] != 0)
                    {
                        // 斜めは両隣が通れるときだけ
                        if (Blocked(iy * _nx + jx, minClass) || Blocked(jy * _nx + ix, minClass)) continue;
                        step = d2;
                    }
                    else step = _res;

                    float mult = _map.ClassOf(j) == 1 ? st.narrowCellCost : 1f;
                    float clearM = _map.ClearM(j);
                    if (clearM < st.preferClearM)
                        mult += st.centerWeight * (st.preferClearM - clearM) / prefMm;
                    if (_costStamp[j] == _markStamp) mult += _costAdd[j];

                    float ng = gi + step * mult;
                    if (_seen[j] == _runStamp && ng >= _g[j] - 1e-9f) continue;

                    _g[j] = ng;
                    _from[j] = i;
                    _seen[j] = _runStamp;
                    float h = Heuristic(j, gx, gy, d2);
                    Push(j, ng + h, h);
                }
            }

            LastExpanded = expanded;
            return Status.NoPath;
        }

        /// <summary>そのマスは通れないか（地図の種類＋差し込んだ「通れないマス」）</summary>
        bool Blocked(int i, int minClass)
        {
            if (_map.ClassOf(i) < minClass) return true;
            return _hasMarks && _blockStamp[i] == _markStamp;
        }

        /// <summary>残りの見込み（8 方向のまっすぐな長さ）。1 歩の重さの下限は 1 倍なので、下から見ている</summary>
        float Heuristic(int i, int gx, int gy, float d2)
        {
            int iy = i / _nx, ix = i - iy * _nx;
            int dx = ix > gx ? ix - gx : gx - ix;
            int dy = iy > gy ? iy - gy : gy - iy;
            int lo = dx < dy ? dx : dy;
            return _res * (dx + dy) + (d2 - 2f * _res) * lo;
        }

        void Trace(int start, int goal)
        {
            _cells.Clear();
            int i = goal;
            while (i != start)
            {
                _cells.Add(i);
                i = _from[i];
                if (i < 0) break;
            }
            _cells.Add(start);
            _cells.Reverse();
        }

        void Finish(Settings st, int minClass)
        {
            // 1 のマスが入ったか
            for (int k = 0; k < _cells.Count; k++)
                if (_map.ClassOf(_cells[k]) == 1) { UsedNarrow = true; break; }

            float raw = 0f;
            for (int k = 1; k < _cells.Count; k++)
                raw += Flat(_map.Center(_cells[k]) - _map.Center(_cells[k - 1])).magnitude;
            LastRawLengthM = raw;

            Simplify(st, minClass);

            float len = 0f;
            for (int k = 1; k < _points.Count; k++)
                len += Flat(_points[k] - _points[k - 1]).magnitude;
            LastLengthM = len;
        }

        // ---------------- 曲がり角の点だけにする ----------------

        /// <summary>
        /// マスの並びを曲がり角の点だけに縮める。
        /// 近道してよいのは「もとの道で通っていた所より障害物に近づかない」ときだけ
        /// （＝近道の上のどのマスも、飛ばしたマスのいちばん狭い所より clear が大きい）。
        /// 端に寄った線に戻らないようにするため。
        /// </summary>
        void Simplify(Settings st, int minClass)
        {
            _points.Clear();
            if (_cells.Count == 0) return;
            _points.Add(_map.Center(_cells[0]));
            if (_cells.Count == 1) return;

            int step = Mathf.Max(1, Mathf.RoundToInt(0.1f / _res));   // 0.1 m ごとに試す
            int maxCells = Mathf.Max(step, Mathf.RoundToInt(st.simplifyMaxM / _res));

            int i = 0;
            while (i < _cells.Count - 1)
            {
                int best = i + 1;
                float floorM = Mathf.Min(_map.ClearM(_cells[i]), _map.ClearM(_cells[i + 1]));

                int j = i + step;
                float runFloor = floorM;
                while (j < _cells.Count && j - i <= maxCells)
                {
                    for (int k = Mathf.Max(i + 1, j - step + 1); k <= j; k++)
                        runFloor = Mathf.Min(runFloor, _map.ClearM(_cells[k]));
                    if (!SegmentOk(_cells[i], _cells[j], runFloor, minClass)) break;
                    best = j;
                    j += step;
                }
                // 最後の点までちょうど届くなら、そこで終わる
                int last = _cells.Count - 1;
                if (best < last && last - i <= maxCells)
                {
                    float f = runFloor;
                    for (int k = best + 1; k <= last; k++) f = Mathf.Min(f, _map.ClearM(_cells[k]));
                    if (SegmentOk(_cells[i], _cells[last], f, minClass)) best = last;
                }

                _points.Add(_map.Center(_cells[best]));
                i = best;
            }
        }

        /// <summary>まっすぐ結んだ線が通れて、もとの道より障害物に近づかないか</summary>
        bool SegmentOk(int a, int b, float floorM, int minClass)
        {
            Vector3 pa = _map.Center(a), pb = _map.Center(b);
            float len = Flat(pb - pa).magnitude;
            int n = Mathf.Max(1, Mathf.CeilToInt(len / (_res * 0.5f)));
            for (int k = 0; k <= n; k++)
            {
                float t = (float)k / n;
                float x = pa.x + (pb.x - pa.x) * t;
                float z = pa.z + (pb.z - pa.z) * t;
                int ix = Mathf.FloorToInt(x / _res);
                int iy = Mathf.FloorToInt(z / _res);
                if (ix < 0 || ix >= _nx || iy < 0 || iy >= _ny) return false;
                int i = iy * _nx + ix;
                if (Blocked(i, minClass)) return false;
                if (_map.ClearM(i) < floorM - 1e-6f) return false;
                if (_hasMarks && _costStamp[i] == _markStamp) return false;   // 重い所へは近道しない
            }
            return true;
        }

        // ---------------- 二分の山 ----------------

        void Push(int index, float f, float h)
        {
            if (_heapCount == _heap.Length) Grow();
            int k = _heapCount++;
            _heap[k] = index; _heapF[k] = f; _heapH[k] = h;
            while (k > 0)
            {
                int p = (k - 1) >> 1;
                if (!Less(k, p)) break;
                Swap(k, p);
                k = p;
            }
        }

        int Pop()
        {
            int top = _heap[0];
            _heapCount--;
            if (_heapCount > 0)
            {
                _heap[0] = _heap[_heapCount];
                _heapF[0] = _heapF[_heapCount];
                _heapH[0] = _heapH[_heapCount];
                int k = 0;
                while (true)
                {
                    int l = (k << 1) + 1, r = l + 1, m = k;
                    if (l < _heapCount && Less(l, m)) m = l;
                    if (r < _heapCount && Less(r, m)) m = r;
                    if (m == k) break;
                    Swap(k, m);
                    k = m;
                }
            }
            return top;
        }

        /// <summary>同じ重さのときの並べ方を決めておく（f → h → マスの番号）。乱数を使わない</summary>
        bool Less(int a, int b)
        {
            if (_heapF[a] < _heapF[b] - 1e-9f) return true;
            if (_heapF[a] > _heapF[b] + 1e-9f) return false;
            if (_heapH[a] < _heapH[b] - 1e-9f) return true;
            if (_heapH[a] > _heapH[b] + 1e-9f) return false;
            return _heap[a] < _heap[b];
        }

        void Swap(int a, int b)
        {
            int i = _heap[a]; _heap[a] = _heap[b]; _heap[b] = i;
            float f = _heapF[a]; _heapF[a] = _heapF[b]; _heapF[b] = f;
            float h = _heapH[a]; _heapH[a] = _heapH[b]; _heapH[b] = h;
        }

        void Grow()
        {
            int size = _heap.Length * 2;
            int[] hi = new int[size];
            float[] hf = new float[size];
            float[] hh = new float[size];
            Array.Copy(_heap, hi, _heapCount);
            Array.Copy(_heapF, hf, _heapCount);
            Array.Copy(_heapH, hh, _heapCount);
            _heap = hi; _heapF = hf; _heapH = hh;
        }

        static Vector3 Flat(Vector3 v) { v.y = 0f; return v; }
    }
}
```

### Assets/ServiceSim/Robot/MapPlanCheck.cs

```csharp
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 確かめ A（依頼 v9 の 7 章 A）：人のいない店で、受け渡し口から全卓へ道を引く。
    ///
    /// 見るもの:
    ///   ・狭い道を使った卓が、サイトの routes の use と同じか（③は 0 卓・⑦は 24 卓）
    ///   ・引いた道の長さと、サイトの routes の length の比（最小・平均・最大）
    ///   ・道探しの時間（平均・中央・最大）
    ///   ・道が引けない卓が無いこと。待機場所へも引けること
    ///
    /// 店も人も建てずに、JSON と地図だけで回せるようにしてある。
    /// Unity のメニュー（ServiceSim/Map Planner）と、Unity の外（mono）の両方から
    /// **同じこの関数**を呼ぶので、出る数字は必ず同じになる。
    /// </summary>
    public static class MapPlanCheck
    {
        /// <summary>1 卓ぶんの結果</summary>
        public struct Row
        {
            public string tableId;
            public string siteUse;      // サイトの routes の use（normal / narrow）
            public string planUse;      // 引いた道（normal / narrow）
            public float siteLengthM;
            public float planLengthM;
            public float ratio;         // 引いた道 ÷ サイトの length
            public int corners;
            public float planMs;
            public string status;
        }

        /// <summary>1 店ぶんの結果</summary>
        public class Result
        {
            public bool ok;
            public string note = "";
            public string report = "";
            public readonly List<Row> rows = new List<Row>();
            public int narrowMine, narrowSite, failed, mismatch, snapped;
            public float ratioMin, ratioMean, ratioMax;
            public float planMsMean, planMsMedian, planMsMax;
            public int standbyOk, standbyCount;

            /// <summary>CSV（table,...）。そのまま書き出せる</summary>
            public string Csv()
            {
                StringBuilder sb = new StringBuilder();
                sb.Append("table,site_use,plan_use,site_length_m,plan_length_m,ratio,corners,plan_ms,status\n");
                for (int i = 0; i < rows.Count; i++)
                {
                    Row r = rows[i];
                    sb.Append(r.tableId).Append(',').Append(r.siteUse).Append(',').Append(r.planUse).Append(',')
                      .Append(F(r.siteLengthM)).Append(',').Append(F(r.planLengthM)).Append(',')
                      .Append(F(r.ratio)).Append(',').Append(r.corners).Append(',')
                      .Append(F(r.planMs)).Append(',').Append(r.status).Append('\n');
                }
                return sb.ToString();
            }
        }

        /// <summary>
        /// 回す。storeJson は店の形の JSON、mapJsonText は地図の JSON。
        /// spec が null なら GridPlanner.Settings.Default で引く。
        /// </summary>
        public static Result Run(string label, string storeJson, string mapJsonText, RobotSpec spec)
        {
            Result res = new Result();
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("## " + label);

            string err;
            JsonValue store = JsonValue.Parse(storeJson, out err);
            if (store == null || !store.IsObject)
            {
                res.note = "店の形の JSON を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }

            RobotMap map = RobotMap.Read(mapJsonText, out err);
            if (map == null)
            {
                res.note = "地図を読めません: " + err;
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine(map.Report());

            GridPlanner.Settings st = spec != null ? spec.PlannerSettings() : GridPlanner.Settings.Default;
            sb.AppendLine("  道の引き方: narrowCellCost " + F(st.narrowCellCost) +
                          " / centerWeight " + F(st.centerWeight) +
                          " / preferClearM " + F(st.preferClearM) +
                          " / maxExpand " + st.maxExpand +
                          " / snapM " + F(st.snapM) +
                          " / simplifyMaxM " + F(st.simplifyMaxM));

            Vector3 pass = Vector3.zero;
            bool hasPass = false;
            List<Vector3> standby = new List<Vector3>();
            JsonValue places = store["places"];
            for (int i = 0; i < places.Count; i++)
            {
                string kind = places[i]["kind"].S("");
                Vector3 p = new Vector3(places[i]["point"][0].F(0f), 0f, places[i]["point"][1].F(0f));
                if (kind == "FoodPass") { pass = p; hasPass = true; }
                else if (kind == "RobotStandby") standby.Add(p);
            }
            if (!hasPass)
            {
                res.note = "places に FoodPass（受け渡し口の前）がありません";
                sb.AppendLine("  " + res.note);
                res.report = sb.ToString().TrimEnd();
                return res;
            }
            sb.AppendLine("  受け渡し口の前 " + P(pass) + " → マスの種類 " + map.ClassAt(pass));

            GridPlanner planner = map.Planner;
            JsonValue fromPass = store["routes"]["from_pass"];
            JsonValue tables = store["tables"];

            List<float> ratios = new List<float>();
            List<float> times = new List<float>();
            List<string> mismatchList = new List<string>();

            for (int i = 0; i < tables.Count; i++)
            {
                Row row = new Row();
                row.tableId = tables[i]["id"].S("T" + (i + 1).ToString("00"));
                Vector3 stop = new Vector3(tables[i]["stop"][0].F(0f), 0f, tables[i]["stop"][1].F(0f));

                JsonValue route = fromPass[row.tableId];
                row.siteUse = route["use"].S("");
                row.siteLengthM = route["length"].F(-1f);
                if (row.siteUse == "narrow") res.narrowSite++;

                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, stop, st);
                row.planMs = planner.LastPlanMs;
                times.Add(row.planMs);
                if (planner.LastStartWasBlocked || planner.LastGoalWasBlocked) res.snapped++;

                if (s != GridPlanner.Status.Ok)
                {
                    res.failed++;
                    row.status = planner.StatusText;
                    res.rows.Add(row);
                    sb.AppendLine("  × " + row.tableId + " 道が引けません: " + row.status);
                    continue;
                }

                row.planUse = planner.UsedNarrow ? "narrow" : "normal";
                if (planner.UsedNarrow) res.narrowMine++;
                row.planLengthM = planner.LastLengthM;
                row.corners = planner.Points.Count;
                row.status = "ok";
                row.ratio = row.siteLengthM > 0.0005f ? row.planLengthM / row.siteLengthM : -1f;
                if (row.ratio > 0f) ratios.Add(row.ratio);
                if (row.planUse != row.siteUse)
                {
                    res.mismatch++;
                    mismatchList.Add(row.tableId + "（サイト " + row.siteUse + " / 引いた道 " + row.planUse + "）");
                }
                res.rows.Add(row);
            }

            sb.AppendLine("  狭い道を使った卓: 引いた道 " + res.narrowMine +
                          " 卓 / サイトの routes の use " + res.narrowSite + " 卓  → " +
                          (res.mismatch == 0 ? "一致" : "ちがう（" + res.mismatch + " 卓）"));
            for (int i = 0; i < mismatchList.Count && i < 12; i++)
                sb.AppendLine("    - " + mismatchList[i]);
            if (mismatchList.Count > 12)
                sb.AppendLine("    - ほか " + (mismatchList.Count - 12) + " 卓");
            sb.AppendLine("  道が引けない卓: " + res.failed + " / " + tables.Count);

            if (ratios.Count > 0)
            {
                ratios.Sort();
                float sum = 0f;
                for (int i = 0; i < ratios.Count; i++) sum += ratios[i];
                res.ratioMin = ratios[0];
                res.ratioMean = sum / ratios.Count;
                res.ratioMax = ratios[ratios.Count - 1];
                sb.AppendLine("  長さの比（引いた道 ÷ サイトの length）: 最小 " + F(res.ratioMin) +
                              " / 平均 " + F(res.ratioMean) + " / 最大 " + F(res.ratioMax) +
                              "（真ん中寄りにした分だけ延びる）");
            }
            if (times.Count > 0)
            {
                times.Sort();
                float sum = 0f;
                for (int i = 0; i < times.Count; i++) sum += times[i];
                res.planMsMean = sum / times.Count;
                res.planMsMedian = times[times.Count / 2];
                res.planMsMax = times[times.Count - 1];
                sb.AppendLine("  道探しの時間: 平均 " + F(res.planMsMean) + " ms / 中央 " +
                              F(res.planMsMedian) + " ms / 最大 " + F(res.planMsMax) + " ms");
            }
            sb.AppendLine("  出発点・行き先が地図で通れないマス（0）だったので寄せた卓: " +
                          res.snapped + " / " + tables.Count);

            res.standbyCount = standby.Count;
            for (int i = 0; i < standby.Count; i++)
            {
                planner.ClearMarks();
                GridPlanner.Status s = planner.Plan(pass, standby[i], st);
                bool good = s == GridPlanner.Status.Ok;
                if (good) res.standbyOk++;
                sb.AppendLine("  待機場所" + (i + 1) + " " + P(standby[i]) +
                              " マスの種類 " + map.ClassAt(standby[i]) + " → " +
                              (good ? "引けた（" + F(planner.LastLengthM) + " m・" +
                                      F(planner.LastPlanMs) + " ms・" +
                                      (planner.UsedNarrow ? "狭い道を使った" : "ふだんの道") + "）"
                                    : "引けない：" + planner.StatusText));
            }

            res.ok = res.failed == 0 && res.mismatch == 0 &&
                     res.standbyOk == res.standbyCount && map.CountsMatch;
            sb.AppendLine("  → " + (res.ok ? "確かめ A は通った" : "確かめ A は通っていない"));
            res.report = sb.ToString().TrimEnd();
            return res;
        }

        static string F(float v) { return v.ToString("0.###", CultureInfo.InvariantCulture); }
        static string P(Vector3 v) { return "(" + F(v.x) + ", " + F(v.z) + ")"; }
    }
}
```

### Assets/ServiceSim/Robot/RobotPathLine.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットがいま引いている道を、床の上に細い線で出す（依頼 v9 の 6 章）。
    ///
    /// 色は仕事で変える:
    ///   料理を載せているとき（卓へ向かっている）… だいだい
    ///   空で戻るとき・待機 … 水色
    ///
    /// 見た目だけ。当たりも NavMesh も持たないので、
    /// ロボットのセンサー・人のよけ方・道探しには入らない。
    /// RobotSpec.showPathLine を外すと出さない（測る回でも出してよいが、切れるようにする）。
    ///
    /// 置き場所: ロボットの prefab には足さない。ロボットが自分で子として作る
    /// （材料も prefab も要らないようにして、シーンの組み立てを変えないため）。
    /// </summary>
    [RequireComponent(typeof(LineRenderer))]
    public class RobotPathLine : MonoBehaviour
    {
        /// <summary>この部品を置く子の名前</summary>
        public const string RootName = "PathLine";

        static readonly Color CarryColor = new Color(1f, 0.55f, 0.1f, 0.9f);   // 料理を載せている
        static readonly Color EmptyColor = new Color(0.25f, 0.75f, 1f, 0.9f);  // 空で戻る

        LineRenderer _line;
        readonly List<Vector3> _points = new List<Vector3>();

        /// <summary>
        /// ロボットの下に線を作る（無ければ）。材料はその場で作るので、
        /// prefab や .asset をいじらなくていい。URP でも読める shader を順に試す
        /// </summary>
        public static RobotPathLine Attach(Transform robot, float widthM)
        {
            if (robot == null) return null;
            Transform t = robot.Find(RootName);
            GameObject go;
            if (t != null) go = t.gameObject;
            else
            {
                go = new GameObject(RootName);
                go.transform.SetParent(robot, false);
            }

            if (go.GetComponent<LineRenderer>() == null) go.AddComponent<LineRenderer>();
            RobotPathLine p = go.GetComponent<RobotPathLine>();
            if (p == null) p = go.AddComponent<RobotPathLine>();
            p.Setup(widthM);
            return p;
        }

        void Setup(float widthM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.useWorldSpace = true;          // 親が回っても線は曲がらない
            _line.alignment = LineAlignment.View;
            _line.textureMode = LineTextureMode.Stretch;
            _line.numCapVertices = 2;
            _line.numCornerVertices = 2;
            _line.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
            _line.receiveShadows = false;
            _line.widthMultiplier = Mathf.Max(0.005f, widthM);
            _line.positionCount = 0;

            if (_line.sharedMaterial == null)
            {
                Shader s = Shader.Find("Universal Render Pipeline/Unlit");
                if (s == null) s = Shader.Find("Sprites/Default");
                if (s == null) s = Shader.Find("Unlit/Color");
                if (s != null)
                {
                    Material m = new Material(s);
                    m.name = "Mat_RobotPathLine";
                    _line.sharedMaterial = m;
                }
            }
        }

        /// <summary>
        /// 線を出し直す。points は道の曲がり角（世界の座標）。
        /// carrying は料理を載せているか。heightM は床から浮かせる高さ。
        /// </summary>
        public void Show(IReadOnlyList<Vector3> points, int fromIndex, Vector3 here,
                         bool carrying, float heightM)
        {
            if (_line == null) _line = GetComponent<LineRenderer>();

            _points.Clear();
            _points.Add(new Vector3(here.x, heightM, here.z));
            if (points != null)
                for (int i = Mathf.Max(0, fromIndex); i < points.Count; i++)
                    _points.Add(new Vector3(points[i].x, heightM, points[i].z));

            if (_points.Count < 2)
            {
                _line.positionCount = 0;
                return;
            }

            _line.positionCount = _points.Count;
            for (int i = 0; i < _points.Count; i++) _line.SetPosition(i, _points[i]);

            Color c = carrying ? CarryColor : EmptyColor;
            _line.startColor = c;
            _line.endColor = new Color(c.r, c.g, c.b, c.a * 0.35f);
            if (_line.sharedMaterial != null && _line.sharedMaterial.HasProperty("_BaseColor"))
                _line.sharedMaterial.SetColor("_BaseColor", c);
        }

        /// <summary>線を消す</summary>
        public void Hide()
        {
            if (_line == null) _line = GetComponent<LineRenderer>();
            _line.positionCount = 0;
        }
    }
}
```

### Assets/ServiceSim/Editor/ServiceSimMapPlanner.cs

```csharp
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 地図の上で道を引く確かめ（依頼 v9 の 7 章 A）。
    ///
    /// 店も人も建てず、回さない。サイトの JSON と地図だけで、
    /// 受け渡し口から全卓へ道を引いて、サイトの routes と突き合わせる。
    /// 中身は ServiceSim.MapPlanCheck（Unity の外からも同じものを呼べる）。
    /// </summary>
    public static class ServiceSimMapPlanner
    {
        const string P1Path = "Assets/ServiceSim/Store/StoreLayout_p1_efficient_corner.asset";
        const string P5Path = "Assets/ServiceSim/Store/StoreLayout_p5_crammed.asset";

        [MenuItem("ServiceSim/Map Planner/③⑦ 確かめ A（地図で道を引く・回さない）", false, 34)]
        public static void CheckBoth()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("# 確かめ A：人のいない店で、受け渡し口から全卓へ道を引く（依頼 v9 の 7 章 A）");
            sb.AppendLine("作った日時: " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
            sb.AppendLine("店も人も建てていない（JSON と地図だけで引いた）");
            sb.AppendLine();

            RobotSpec spec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            if (spec == null)
                sb.AppendLine("※ RobotSpec が見つからないので、道の引き方は既定の値で引きました");

            bool ok = true;
            ok &= One(sb, P1Path, "③ 効率重視型・入口が左下の角", spec);
            sb.AppendLine();
            ok &= One(sb, P5Path, "⑦ ぎっしり型", spec);

            sb.AppendLine();
            sb.AppendLine(ok ? "→ ③⑦ ともに確かめ A は通った" : "→ 通っていないものがある（上の × を見てください）");

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir))
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string txt = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_planner_checkA.txt");
                    File.WriteAllText(txt, sb.ToString(), new UTF8Encoding(false));
                    sb.AppendLine("書き出し: " + txt);
                }
                catch (Exception e) { sb.AppendLine("書き出せません: " + e.Message); }
            }

            if (ok) Debug.Log("[ServiceSim] " + sb.ToString());
            else Debug.LogError("[ServiceSim] " + sb.ToString());
        }

        static bool One(StringBuilder sb, string assetPath, string label, RobotSpec spec)
        {
            JsonStoreLayout layout = AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath);
            if (layout == null)
            {
                sb.AppendLine("## " + label + "\n  店の形が見つかりません: " + assetPath);
                return false;
            }
            if (layout.layoutJson == null || layout.mapJson == null)
            {
                sb.AppendLine("## " + label + "（" + layout.layoutFileName + "）");
                sb.AppendLine("  " + (layout.layoutJson == null ? "layoutJson" : "mapJson") +
                              " が入っていません（依頼 v9 の 0 章を確かめてください）");
                return false;
            }

            MapPlanCheck.Result res = MapPlanCheck.Run(
                label + "（" + layout.layoutFileName + "）",
                layout.layoutJson.text, layout.mapJson.text, spec);
            sb.AppendLine(res.report);

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(ServiceSimSetup.ExperimentPlanPath);
            string outDir = plan != null ? plan.screenshotDir : null;
            if (!string.IsNullOrEmpty(outDir) && res.rows.Count > 0)
            {
                try
                {
                    Directory.CreateDirectory(outDir);
                    string csv = Path.Combine(outDir,
                        DateTime.Now.ToString("yyyyMMdd_HHmm") + "_map_plan_" +
                        layout.layoutFileName + ".csv");
                    File.WriteAllText(csv, res.Csv(), new UTF8Encoding(false));
                    sb.AppendLine("  CSV: " + csv);
                }
                catch (Exception e) { sb.AppendLine("  CSV を書けません: " + e.Message); }
            }
            return res.ok;
        }
    }
}
```

### 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; } }

        /// <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();

            // ---- 台どうしでふさぎ合ったら、決まった順で片方が譲る ----
            // MapPlanner では「料理を載せている方が先」（依頼 v9 の 4 章）、
            // SiteRoutes ではこれまでの「番号の小さい台が先」（v5〜v8b）
            if (UseMapPlanner) UpdateRobotYieldMap(dt, reading);
            else UpdateRobotYield(dt, reading);

            // ---- 細い通路の譲り合い ----
            bool waitingAtEntrance = !_robotYield && !_mapYield && UpdateAisleYielding();

            // ---- 走る・止まる ----
            bool blocked = reading.blocked;

            // 原因は、ブレーキを踏み始めた瞬間（前が塞がったと分かった瞬間）のものを覚えておく。
            // 速さが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;

            float target = wantMove ? spec.maxSpeed : 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);
        }

        void FaceAndMove(float dt)
        {
            if (_repathCooldown > 0f) _repathCooldown -= dt;

            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;
                // 向きを変える
                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;
        }

        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/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;

        [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/Store/JsonStoreLayout.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// サイトが書き出した「店の形の JSON」を読んで店を建てる StoreLayout。
    ///
    /// 2026-10-22 にエイイチと決めた「何について正か」（unity_design_v0.md の 0 章）:
    ///   店の形（卓・壁・部屋・止まる位置）は **サイトが正**。Unity は形を作らない。
    /// なので、このクラスは JSON を StorePlan に写すだけで、足りないものを自分で埋めない。
    /// 合わないものが出たら、埋めずに報告へ書いて **建てずに止める**（Compose が null を返す）。
    ///
    /// 読むもの:
    ///   layoutJson   https://pixelstomotors.com/sim/layouts/unity/{名前}.json を写したもの
    ///   summaryJson  https://pixelstomotors.com/sim/layouts/summary.json を写したもの
    /// 版の番号（meta.generation）が summary.json と違ったら建てない。
    ///
    /// 座標: JSON の x → Unity の X、JSON の y → Unity の Z、高さ → Y。単位は m。
    ///       原点は店の外壁の内側の左下の角。
    /// </summary>
    [CreateAssetMenu(fileName = "StoreLayout_Json", menuName = "ServiceSim/Json Store Layout", order = 12)]
    public class JsonStoreLayout : StoreLayout
    {
        [Header("読む JSON（サイトから写したもの）")]
        [Tooltip("店の形の JSON（unity/{名前}.json）")]
        public TextAsset layoutJson;

        [Tooltip("一覧の JSON（summary.json）。版の番号を突き合わせる")]
        public TextAsset summaryJson;

        [Tooltip("ロボットが走れる所の地図（unity/{名前}_map.json。依頼 v9）。" +
                 "入れないと MapPlanner では走れない")]
        public TextAsset mapJson;

        [Tooltip("summary.json の file と突き合わせる名前（例 p1_efficient_corner）")]
        public string layoutFileName = "";

        [Header("写した記録（報告に出す）")]
        [Tooltip("サイトから写した日時")]
        public string fetchedAt = "";

        [Tooltip("写し元の URL")]
        public string sourceUrl = "";

        [Header("見た目だけ（当たりも NavMesh も持たない）")]
        [Tooltip("背もたれの板の厚み（m）。机と反対側のふちに立てる。0 で出さない")]
        public float benchBackThickness = 0.08f;

        [Tooltip("座面の見た目を、ベンチのふちから内側へ詰める長さ（m）。0 でふちまで")]
        public float benchSeatInset = 0f;

        [Tooltip("ロボットが通らない所（robot_keepout・会計の列）の印の厚み（m）。0 で出さない。" +
                 "当たりは持たないので、人は通れる。NavMesh にも効かない")]
        public float keepoutMarkThickness = 0.02f;

        // ---- 建てたときの覚え書き（報告に出す。Compose のたびに作り直す）----
        readonly List<string> _problems = new List<string>();
        readonly List<string> _doors = new List<string>();
        readonly List<string> _keepouts = new List<string>();
        string _generation = "";
        string _storeName = "";
        float _jsonNarrowWidth = 0f;
        float _normalWidth = 0f;
        int _tableCount, _seatCount, _narrowAisleCount, _chairCount, _spotCount, _seatPointCount;
        int _roomCount, _placeCount, _wallCount, _dividerCount;
        int _walkableRoomCount, _staffOnlyRoomCount;
        int _routeFromPass, _routeBetween;

        // ---- 地図（依頼 v9）----
        RobotMap _map;
        readonly List<string> _mapNotes = new List<string>();

        public override string StoreKindName
        {
            get { return "JsonStoreLayout(" + (string.IsNullOrEmpty(layoutFileName) ? "?" : layoutFileName) + ")"; }
        }

        /// <summary>この JSON の版の番号（meta.generation）。建てたあとに読める</summary>
        public string Generation { get { return _generation; } }

        /// <summary>読んだ地図（依頼 v9）。建てたあとに読める。読めなければ null</summary>
        public RobotMap Map { get { return _map; } }

        /// <summary>
        /// 地図の版の番号（map.json の meta.generation）。読んでいなければ空。
        /// run_info.json に出す
        /// </summary>
        public string MapGeneration { get { return _map != null ? _map.generation : ""; } }

        /// <summary>
        /// 版の番号を、建てる前でも読む（試算の値と突き合わせるのに使う）。
        /// 建てたあとは Compose が読んだものを返す。
        /// </summary>
        public string ReadGeneration()
        {
            if (!string.IsNullOrEmpty(_generation)) return _generation;
            if (layoutJson == null) return "";
            string err;
            JsonValue root = JsonValue.Parse(layoutJson.text, out err);
            return root != null && root.IsObject ? root["meta"]["generation"].S("") : "";
        }

        /// <summary>建てた卓の数（JSON の tables の数）</summary>
        public int TableCount { get { return _tableCount; } }

        /// <summary>
        /// サイトの決まりの「ふだん走らせる道の幅」（meta.settings.normal_width, m）。
        /// これ未満の幅の通路が「狭い通路」。0 なら JSON に無い
        /// </summary>
        public float NormalWidth { get { return _normalWidth; } }

        /// <summary>
        /// 試算の値（serving-robot-params.json）の stores の名前（fam_p1 / fam_p5）。
        /// layoutFileName（p1_efficient_corner / p5_crammed）の頭の "p1" "p5" から作る。
        /// 合っているかは SimParams.Store() が null かどうかで分かる。
        /// </summary>
        public string ParamsStoreKey
        {
            get
            {
                if (string.IsNullOrEmpty(layoutFileName)) return "";
                int cut = layoutFileName.IndexOf('_');
                string head = cut > 0 ? layoutFileName.Substring(0, cut) : layoutFileName;
                return "fam_" + head;
            }
        }

        /// <summary>
        /// Build Store が store_report と Console に出す欄。
        /// JSON から建てたときは、版の番号・写した日時・数えたもの・合わなかったものをここに出す。
        /// </summary>
        public override string FrontFitReport()
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("JSON から建てた店（Unity では形を作っていない）");
            sb.AppendLine("  ファイル: " + (layoutJson != null ? layoutJson.name : "（未設定）") +
                          " / 名前: " + _storeName);
            sb.AppendLine("  版の番号 (meta.generation): " + _generation);
            sb.AppendLine("  写した日時: " + (string.IsNullOrEmpty(fetchedAt) ? "（未記入）" : fetchedAt));
            sb.AppendLine("  写し元: " + (string.IsNullOrEmpty(sourceUrl) ? "（未記入）" : sourceUrl));
            sb.AppendLine("  卓 " + _tableCount + " / 席 " + _seatCount +
                          " / 細い通路 " + _narrowAisleCount + " 本（幅 " + F(_jsonNarrowWidth) + " m）");
            sb.AppendLine("  通れない部屋 " + _roomCount +
                          " / 通れる部屋 " + _walkableRoomCount +
                          "（うちスタッフだけ " + _staffOnlyRoomCount + "）");
            sb.AppendLine("  場所の点 " + _placeCount +
                          " / 壁 " + _wallCount + " / 仕切り " + _dividerCount +
                          " / 椅子 " + _chairCount +
                          " / 席に着く点 " + _spotCount + " / 座る位置 " + _seatPointCount);
            sb.AppendLine("  道順 (routes): 受け渡し口から " + _routeFromPass + " 本 / 卓から卓 " +
                          _routeBetween + " 本"
                          + "（狭い道を使うかは routes の use が正。Unity では印を持たない）");
            if (_map == null)
                sb.AppendLine("  地図 (map.json): 読んでいません" +
                              (mapJson == null ? "（mapJson が未設定）" : ""));
            else
            {
                sb.AppendLine(_map.Report());
                for (int i = 0; i < _mapNotes.Count; i++) sb.AppendLine("    " + _mapNotes[i]);
            }
            sb.AppendLine("  doors: " + (_doors.Count == 0
                ? "0（壁の切れ目として、もう空いている）"
                : _doors.Count + " → " + string.Join(" / ", _doors.ToArray())));
            sb.AppendLine("  ロボットが通らない所 (robot_keepout): " +
                          (_keepouts.Count == 0 ? "0"
                           : _keepouts.Count + " → " + string.Join(" / ", _keepouts.ToArray()) +
                             "（部屋や壁として建てない。人は通れる。床に印だけ出す）"));
            sb.AppendLine("  refuges（よけ場所）: 0（JSON にまだ無い。空のまま）");
            sb.AppendLine("  止まる位置は JSON の stop をそのまま使う"
                          + "（deliveryOffsetFromAisleCenter と aisleEntranceSetback は使わない）");
            if (_problems.Count == 0) sb.AppendLine("  合わないもの: なし");
            else
            {
                sb.AppendLine("  合わないもの: " + _problems.Count + " 件（建てていない）");
                for (int i = 0; i < _problems.Count; i++) sb.AppendLine("    - " + _problems[i]);
            }
            return sb.ToString().TrimEnd();
        }

        protected override StorePlan Compose()
        {
            _problems.Clear();
            _doors.Clear();
            _keepouts.Clear();
            _mapNotes.Clear();
            _map = null;
            _generation = ""; _storeName = ""; _jsonNarrowWidth = 0f; _normalWidth = 0f;
            _tableCount = _seatCount = _narrowAisleCount = _chairCount = 0;
            _spotCount = _seatPointCount = _roomCount = _placeCount = _wallCount = _dividerCount = 0;
            _walkableRoomCount = _staffOnlyRoomCount = 0;
            _routeFromPass = _routeBetween = 0;

            if (layoutJson == null)
            {
                Fail("店の形の JSON（layoutJson）が入っていません");
                return null;
            }

            string err;
            JsonValue root = JsonValue.Parse(layoutJson.text, out err);
            if (root == null || !root.IsObject)
            {
                Fail("店の形の JSON を読めません: " + err);
                return null;
            }

            JsonValue meta = root["meta"];
            _generation = meta["generation"].S("");
            _storeName = root["name"].S("");

            // サイトの決まりの「ふだん走らせる道の幅」（meta.settings.normal_width）。
            // これ未満の通路が「狭い通路」。往復ごとの測りを中と外で分けるのに使う（v5 3章）
            _normalWidth = meta["settings"]["normal_width"].F(0f);

            // 地図の 1 マスと突き合わせる（依頼 v9 の 1 章）
            _mapSettingsRes = meta["settings"]["res"].F(0f);

            if (!CheckGeneration()) return null;

            // ---- 高さ（JSON の meta.heights。Unity 側では決めない）----
            JsonValue heights = meta["heights"];
            float hWall = Req(heights, "wall", "meta.heights.wall");
            float hTable = Req(heights, "table", "meta.heights.table");
            float hBench = Req(heights, "bench", "meta.heights.bench");
            float hBenchSeat = Req(heights, "bench_seat", "meta.heights.bench_seat");
            float hChair = Req(heights, "chair", "meta.heights.chair");
            float hCounter = Req(heights, "counter", "meta.heights.counter");
            float hPartition = heights["partition"].F(1.2f);   // これ以下の walls は仕切り


            StorePlan plan = new StorePlan();

            // ---- 床 ----
            JsonValue floor = root["floor"];
            for (int i = 0; i < floor.Count; i++)
            {
                PlanRect r;
                if (TryRect(floor[i], "floor[" + i + "]", out r)) plan.AddFloor(r);
            }
            if (plan.floorPieces.Count == 0) Fail("floor が空です");

            // ---- 外壁 ----
            JsonValue outerWalls = root["outer_walls"];
            for (int i = 0; i < outerWalls.Count; i++)
            {
                PlanRect r;
                if (!TryRect(outerWalls[i], "outer_walls[" + i + "]", out r)) continue;
                plan.AddBox("OuterWall" + i.ToString("00"), BoxStyle.Wall, r, hWall);
                _wallCount++;
            }

            // ---- 中の壁・仕切り（高さで分ける）----
            JsonValue walls = root["walls"];
            for (int i = 0; i < walls.Count; i++)
            {
                JsonValue w = walls[i];
                PlanRect r;
                if (!TryRect(w["rect"], "walls[" + i + "].rect", out r)) continue;
                float h = w["height"].F(hWall);
                bool divider = h <= hPartition + 0.0005f;
                string label = w["label"].S("Wall" + i.ToString("00"));
                plan.AddBox(Safe(label) + i.ToString("00"),
                            divider ? BoxStyle.Divider : BoxStyle.Wall, r, h);
                if (divider) _dividerCount++; else _wallCount++;
            }

            // ---- 場所の点（先に入れる。PointOf がこちらを先に拾うように）----
            Dictionary<PlaceKind, Vector3> firstPoint = new Dictionary<PlaceKind, Vector3>();
            JsonValue places = root["places"];
            for (int i = 0; i < places.Count; i++)
            {
                JsonValue p = places[i];
                string kindName = p["kind"].S("");
                PlaceKind kind;
                if (!TryParseKind(kindName, out kind))
                {
                    Fail("places[" + i + "]: PlaceKind に無い kind \"" + kindName + "\"");
                    continue;
                }
                Vector3 point;
                if (!TryPoint(p["point"], "places[" + i + "].point", out point)) continue;

                PlanRect rect;
                if (!p.Has("rect") || !TryRect(p["rect"], "places[" + i + "].rect", out rect))
                    rect = new PlanRect(0f, 0f, 0f, 0f);

                // 箱は置かない（通れない箱は rooms が持つ）。ここは点だけ。
                plan.AddPlace(kind, Safe(p["label"].S(kindName)) + i.ToString("00"), rect, 0f, point, false);
                if (!firstPoint.ContainsKey(kind)) firstPoint[kind] = point;
                _placeCount++;
            }

            // ---- 部屋（通れない箱）----
            JsonValue rooms = root["rooms"];
            for (int i = 0; i < rooms.Count; i++)
            {
                JsonValue rm = rooms[i];
                string label = rm["label"].S("");
                PlaceKind kind;
                if (!TryRoomKind(label, out kind))
                {
                    Fail("rooms[" + i + "]: PlaceKind に対応の無いラベル \"" + label + "\"");
                    continue;
                }
                PlanRect r;
                if (!TryRect(rm["rect"], "rooms[" + i + "].rect", out r)) continue;

                bool walkable = rm["walkable"].B(false);
                bool staffOnly = rm["staff_only"].B(false);

                // スタッフだけが入れる区画は、いまの作りでは「レジの内側」だけ
                // （NavMeshBaker.AddRegisterBackArea が PlaceKind.RegisterBack の rect を見て
                //   区画の番号を変え、客の NavMeshAgent がその区画を外して歩く）。
                // ほかの部屋に staff_only が付いたら、こちらで作らずに報告して止める。
                if (staffOnly && kind != PlaceKind.RegisterBack)
                {
                    Fail("rooms[" + i + "] \"" + label + "\": staff_only は今の作りでは" +
                         "レジの内側（RegisterBack）だけです");
                    continue;
                }

                if (walkable)
                {
                    // 通れない箱にしない（人が入る部屋）。場所の点だけ置く。
                    // 同じ種類の点が places にあれば、そちらが区画の広がりも持っているので足さない。
                    if (!firstPoint.ContainsKey(kind))
                    {
                        plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, 0f, r.Center, false);
                        firstPoint[kind] = r.Center;
                    }
                    _walkableRoomCount++;
                    if (staffOnly) _staffOnlyRoomCount++;
                    continue;
                }

                // レジとドリンクバーはカウンターの高さ、それ以外は壁の高さ
                float h = (kind == PlaceKind.Register || kind == PlaceKind.DrinkBar) ? hCounter : hWall;

                // 点は、同じ種類の場所の点（JSON が決めた立ち位置）に合わせる。無ければ真ん中
                Vector3 point;
                if (!firstPoint.TryGetValue(kind, out point)) point = r.Center;

                plan.AddPlace(kind, Safe(label) + "_Room" + i.ToString("00"), r, h, point, true);
                _roomCount++;
            }

            // ---- 細い通路 ----
            Dictionary<string, int> aisleIndexById = new Dictionary<string, int>();
            JsonValue aisles = root["aisles"];
            for (int i = 0; i < aisles.Count; i++)
            {
                JsonValue a = aisles[i];
                PlanRect r;
                if (!TryRect(a["rect"], "aisles[" + i + "].rect", out r)) continue;
                Vector3 entrance;
                if (!TryPoint(a["entrance"], "aisles[" + i + "].entrance", out entrance)) continue;
                string id = a["id"].S("A" + (i + 1).ToString("00"));
                bool narrow = a["narrow"].B(true);
                aisleIndexById[id] = plan.aisles.Count;
                plan.AddAisle(id, r, narrow, entrance);
                float aw = a["width"].F(0f);
                if (narrow)
                {
                    _narrowAisleCount++;
                    if (_jsonNarrowWidth <= 0f) _jsonNarrowWidth = aw;
                    else if (Mathf.Abs(_jsonNarrowWidth - aw) > 0.0005f)
                        Fail("細い通路の幅がそろっていません: " + F(_jsonNarrowWidth) + " と " + F(aw));
                }
            }

            // ---- 卓 ----
            JsonValue tables = root["tables"];
            for (int i = 0; i < tables.Count; i++)
            {
                ComposeTable(plan, tables[i], i, aisleIndexById,
                             hTable, hBench, hBenchSeat, hChair);
            }
            _tableCount = plan.seats.Count;

            // ---- ロボットが通らない所（robot_keepout。会計の列）----
            // 部屋や壁として建てない（人は通れる）。床に印だけ出す。
            // サイトの道順はもうこの中を通らないように作られている
            JsonValue keepout = root["robot_keepout"];
            for (int i = 0; i < keepout.Count; i++)
            {
                PlanRect kr;
                string where = "robot_keepout[" + i + "]";
                if (!TryRect(keepout[i]["rect"], where + ".rect", out kr)) continue;
                string label = keepout[i]["label"].S("");
                _keepouts.Add(label.Length > 0 ? label : where);
                if (keepoutMarkThickness > 0.0005f)
                {
                    PlanBox kb = plan.AddBox("RobotKeepout" + (i + 1).ToString("00"),
                                             BoxStyle.KeepoutMark, kr, keepoutMarkThickness);
                    if (kb != null) kb.baseY = 0.01f;   // 床より少し上に置いて、真上から見えるように
                }
            }

            // ---- ロボットの道順（v5b。サイトが決めた曲がり角の点）----
            plan.routes = ReadRoutes(root["routes"]);

            // ---- ロボットが走れる所の地図（依頼 v9）----
            plan.robotMap = ReadMap(plan);

            // ---- doors（箱は置かない。報告に一覧だけ出す）----
            JsonValue doors = root["doors"];
            for (int i = 0; i < doors.Count; i++)
            {
                JsonValue d = doors[i];
                string label = d["label"].S(d["id"].S("door" + i));
                PlanRect r;
                string where = TryRect(d["rect"], "doors[" + i + "].rect", out r) ? r.ToString() : "（rect なし）";
                _doors.Add(label + " " + where);
            }

            // ---- 細い通路の幅は JSON が正。.asset の値が違えば JSON に合わせる ----
            // 前は合わないと建てずに止めていたが、サイトが通路の幅を変えるたびに
            // .asset の古い値で止まってしまう（⑦を 0.8 → 0.95 m にしたときに起きた）。
            // JSON が正なので、ここで .asset の値を JSON に合わせる（店の形は変えない）。
            if (_jsonNarrowWidth > 0f && Mathf.Abs(AisleWidth - _jsonNarrowWidth) > 0.0005f)
            {
                float was = AisleWidth;
                aisleWidthOptions = new float[] { _jsonNarrowWidth };
                aisleWidthIndex = 0;
                Debug.Log("[ServiceSim] 細い通路の幅を JSON に合わせました: .asset " + F(was) +
                          " m → JSON " + F(_jsonNarrowWidth) + " m（" + name + "）");
            }

            if (_problems.Count > 0)
            {
                Debug.LogError("[ServiceSim] " + name + ": JSON と合わないものが " + _problems.Count +
                               " 件あるので建てませんでした。\n" + FrontFitReport());
                return null;
            }

            return plan;
        }

        // ---------------- 地図（依頼 v9 の 1 章）----------------

        /// <summary>
        /// ロボットが走れる所の地図を読み、店の形と合っているかを確かめる。
        ///
        /// 確かめること（依頼 v9 の 1 章）:
        ///   ・2・1・0 の数が JSON の counts と同じ（RobotMap がやる）
        ///   ・版の番号が店の JSON と同じ
        ///   ・受け渡し口の点・全卓の止まる位置が 1 以上のマス
        ///   ・待機場所の真ん中も 1 以上（壁ぎわなので 1 になる）
        /// 合わなければ Fail に入れて建てずに止める（店の形は Unity で直さない）。
        /// </summary>
        RobotMap ReadMap(StorePlan plan)
        {
            if (mapJson == null)
            {
                Fail("地図の JSON（mapJson）が入っていません（依頼 v9 の 0 章・" +
                     "unity/" + layoutFileName + "_map.json を写して結んでください）");
                return null;
            }

            string err;
            RobotMap map = RobotMap.Read(mapJson.text, out err);
            if (map == null)
            {
                Fail("地図を読めません: " + err);
                return null;
            }
            _map = map;

            if (!map.CountsMatch)
                Fail("地図の 2・1・0 の数が、地図の JSON の counts と合いません");

            if (map.generation != _generation)
                Fail("地図の版の番号が店の形と違います: 地図 " + map.generation +
                     " / 店の形 " + _generation + "（写し直してください）");

            // 1 マスの大きさが店の形の settings.res と同じか
            float res = _mapSettingsRes;
            if (res > 0.0001f && Mathf.Abs(res - map.res) > 0.0001f)
                Fail("地図の 1 マス（" + F(map.res) + " m）が、店の形の meta.settings.res（" +
                     F(res) + " m）と違います");

            // 点が通れるマスに乗っているか
            int offStop = 0;
            List<string> offStopList = new List<string>();
            for (int i = 0; i < plan.seats.Count; i++)
            {
                PlanSeat s = plan.seats[i];
                int c = map.ClassAt(s.deliveryPoint);
                if (c >= 1) continue;
                offStop++;
                if (offStopList.Count < 8)
                    offStopList.Add("T" + s.tableId.ToString("00") + " " + P(s.deliveryPoint));
            }
            if (offStop > 0)
                Fail("止まる位置が地図で通れないマス（0）の卓が " + offStop + " 卓あります: " +
                     string.Join(" / ", offStopList.ToArray()));
            else
                _mapNotes.Add("止まる位置: " + plan.seats.Count + " 卓すべて 1 以上のマス");

            int offPlace = 0;
            List<string> offPlaceList = new List<string>();
            for (int i = 0; i < plan.places.Count; i++)
            {
                PlanPlace p = plan.places[i];
                if (p.kind != PlaceKind.FoodPass && p.kind != PlaceKind.RobotStandby) continue;
                int c = map.ClassAt(p.point);
                if (c >= 1) { _mapNotes.Add(p.kind + " " + P(p.point) + " → マスの種類 " + c); continue; }
                offPlace++;
                offPlaceList.Add(p.kind + " " + P(p.point));
            }
            if (offPlace > 0)
                Fail("受け渡し口・待機場所が地図で通れないマス（0）です: " +
                     string.Join(" / ", offPlaceList.ToArray()));

            return map;
        }

        /// <summary>店の形の meta.settings.res（地図の 1 マスと突き合わせる）</summary>
        float _mapSettingsRes;

        /// <summary>
        /// routes を読む（v5b）。from_pass["T05"] と between["T05|T12"]。
        /// 点は [x, y] で、JSON の y → Unity の Z。
        /// </summary>
        RouteBook ReadRoutes(JsonValue routes)
        {
            if (!routes.IsObject)
            {
                Fail("routes がありません（v5b の版の JSON を写してください）");
                return null;
            }
            RouteBook book = new RouteBook();

            JsonValue fromPass = routes["from_pass"];
            foreach (string id in fromPass.Keys)
            {
                RouteBook.Route r = ReadRoute(fromPass[id], "routes.from_pass[" + id + "]");
                if (r != null) book.AddFromPass(NumberFromId(id, -1), r);
            }

            JsonValue between = routes["between"];
            foreach (string key in between.Keys)
            {
                int bar = key.IndexOf('|');
                if (bar <= 0)
                {
                    Fail("routes.between のキーが \"T01|T02\" の形ではありません: " + key);
                    continue;
                }
                RouteBook.Route r = ReadRoute(between[key], "routes.between[" + key + "]");
                if (r != null)
                    book.AddBetween(NumberFromId(key.Substring(0, bar), -1),
                                    NumberFromId(key.Substring(bar + 1), -1), r);
            }

            _routeFromPass = book.FromPassCount;
            _routeBetween = book.BetweenCount;
            if (_routeFromPass == 0) Fail("routes.from_pass が空です");
            return book;
        }

        RouteBook.Route ReadRoute(JsonValue v, string where)
        {
            JsonValue pts = v["points"];
            if (pts.Count < 2)
            {
                Fail(where + ": points が 2 点未満です");
                return null;
            }
            RouteBook.Route r = new RouteBook.Route();
            for (int i = 0; i < pts.Count; i++)
            {
                Vector3 p;
                if (!TryPoint(pts[i], where + ".points[" + i + "]", out p)) return null;
                r.points.Add(p);
            }
            r.use = v["use"].S("normal");
            r.width = v["width"].F(0f);
            r.length = v["length"].F(r.PolylineLength);
            if (r.use != "normal" && r.use != "narrow")
            {
                Fail(where + ": use が normal / narrow ではありません（" + r.use + "）");
                return null;
            }
            return r;
        }

        // ---------------- 卓 ----------------

        void ComposeTable(StorePlan plan, JsonValue t, int index,
                          Dictionary<string, int> aisleIndexById,
                          float hTable, float hBench, float hBenchSeat, float hChair)
        {
            string id = t["id"].S("T" + (index + 1).ToString("00"));
            string where = "tables[" + index + "] " + id;

            SeatKind kind;
            string type = t["type"].S("");
            if (!TryTableKind(type, out kind))
            {
                Fail(where + ": SeatKind に対応の無い type \"" + type + "\"");
                return;
            }

            JsonValue parts = t["parts"];
            PlanRect top;
            if (!TryRect(parts["top"], where + ".parts.top", out top)) return;

            Vector3 stop;
            if (!TryPoint(t["stop"], where + ".stop", out stop)) return;

            PlanSeat seat = new PlanSeat();
            seat.tableId = NumberFromId(id, index + 1);
            seat.kind = kind;
            seat.tableRect = top;
            seat.tableHeight = hTable;
            seat.seatCount = t["seats"].I(0);
            seat.deliveryPoint = stop;             // JSON の stop をそのまま（ずらさない）

            // 通る細い通路
            string aisleId = t["aisle"].S(null);
            if (string.IsNullOrEmpty(aisleId)) seat.aisleIndex = -1;
            else
            {
                int ai;
                if (aisleIndexById.TryGetValue(aisleId, out ai)) seat.aisleIndex = ai;
                else { Fail(where + ": aisles に無い通路 \"" + aisleId + "\""); return; }
            }

            // 天板
            plan.AddBox(id + "_Top", BoxStyle.Table, top, hTable);

            // ベンチ。当たりの箱はこれまでと同じ大きさ・位置・高さのまま、見た目を持たない。
            // 見た目は、当たりなしの座面と背もたれで作る（本物に近い形にするため）
            JsonValue benches = parts["benches"];
            for (int b = 0; b < benches.Count; b++)
            {
                PlanRect r;
                if (!TryRect(benches[b], where + ".parts.benches[" + b + "]", out r)) continue;
                plan.AddBox(id + "_Bench" + (b + 1), BoxStyle.BenchBody, r, hBench);
                AddBenchLook(plan, id + "_Bench" + (b + 1), r, top, hBench, hBenchSeat);
            }

            // 椅子
            JsonValue chairs = parts["chairs"];
            for (int c = 0; c < chairs.Count; c++)
            {
                JsonValue ch = chairs[c];
                PlanRect r;
                if (!TryRect(ch["rect"], where + ".parts.chairs[" + c + "].rect", out r)) continue;
                PlanChair chair = new PlanChair();
                chair.rect = r;
                chair.height = hChair;
                chair.pullable = ch["pullable"].B(false);
                JsonValue dir = ch["pull_dir"];
                chair.pullDirection = new Vector3(dir[0].F(0f), 0f, dir[1].F(0f));
                if (chair.pullDirection.sqrMagnitude > 0.000001f)
                    chair.pullDirection = chair.pullDirection.normalized;
                seat.chairs.Add(chair);
                _chairCount++;
            }

            // 座る位置
            JsonValue sp = parts["seat_points"];
            for (int s = 0; s < sp.Count; s++)
            {
                Vector3 p;
                if (!TryPoint(sp[s], where + ".parts.seat_points[" + s + "]", out p)) continue;
                seat.seatPoints.Add(p);
                _seatPointCount++;
            }

            // 席に着くために立つ点
            JsonValue spots = t["spots"];
            for (int s = 0; s < spots.Count; s++)
            {
                Vector3 p;
                if (!TryPoint(spots[s]["point"], where + ".spots[" + s + "].point", out p)) continue;
                seat.spots.Add(new PlanSeatSpot(spots[s]["side"].S("spot" + (s + 1)), p));
                _spotCount++;
            }

            plan.seats.Add(seat);
            _seatCount += seat.seatCount;
        }

        /// <summary>
        /// ベンチの見た目（当たりなし）。座面と背もたれを、ベンチの箱の中に重ならないように置く。
        ///   背もたれ … 机と反対側のふちに、厚さ benchBackThickness・高さ benchHeight（1.1 m）
        ///   座面     … 背もたれの板を除いた残り、高さ seatHeight（0.45 m）
        /// どちらも当たりを持たないので、NavMesh・前のセンサー・左右の空きの測りには効かない。
        /// </summary>
        void AddBenchLook(StorePlan plan, string name, PlanRect bench, PlanRect table,
                          float benchHeight, float seatHeight)
        {
            float back = Mathf.Max(0f, benchBackThickness);
            PlanRect seatRect = bench;

            if (back > 0.0005f)
            {
                // 机から遠い側のふちを選ぶ。長い向きではなく、机との位置関係で決める
                float dx = bench.CenterX - table.CenterX;
                float dz = bench.CenterZ - table.CenterZ;
                bool alongZ = Mathf.Abs(dz) >= Mathf.Abs(dx);   // 机の南北にあるベンチか

                if (alongZ && bench.SizeZ > back + 0.01f)
                {
                    if (dz >= 0f)   // ベンチは机の北 → 背もたれは北のふち
                    {
                        plan.AddBox(name + "_Back", BoxStyle.BenchBack,
                            new PlanRect(bench.xMin, bench.zMax - back, bench.xMax, bench.zMax), benchHeight);
                        seatRect = new PlanRect(bench.xMin, bench.zMin, bench.xMax, bench.zMax - back);
                    }
                    else
                    {
                        plan.AddBox(name + "_Back", BoxStyle.BenchBack,
                            new PlanRect(bench.xMin, bench.zMin, bench.xMax, bench.zMin + back), benchHeight);
                        seatRect = new PlanRect(bench.xMin, bench.zMin + back, bench.xMax, bench.zMax);
                    }
                }
                else if (!alongZ && bench.SizeX > back + 0.01f)
                {
                    if (dx >= 0f)   // ベンチは机の東 → 背もたれは東のふち
                    {
                        plan.AddBox(name + "_Back", BoxStyle.BenchBack,
                            new PlanRect(bench.xMax - back, bench.zMin, bench.xMax, bench.zMax), benchHeight);
                        seatRect = new PlanRect(bench.xMin, bench.zMin, bench.xMax - back, bench.zMax);
                    }
                    else
                    {
                        plan.AddBox(name + "_Back", BoxStyle.BenchBack,
                            new PlanRect(bench.xMin, bench.zMin, bench.xMin + back, bench.zMax), benchHeight);
                        seatRect = new PlanRect(bench.xMin + back, bench.zMin, bench.xMax, bench.zMax);
                    }
                }
            }

            float m = Mathf.Max(0f, benchSeatInset);
            PlanRect r = new PlanRect(seatRect.xMin + m, seatRect.zMin + m,
                                      seatRect.xMax - m, seatRect.zMax - m);
            if (!r.IsEmpty && seatHeight > 0.0005f)
                plan.AddBox(name + "_Seat", BoxStyle.BenchSeat, r, seatHeight);
        }

        // ---------------- 小さな道具 ----------------

        bool CheckGeneration()
        {
            if (string.IsNullOrEmpty(_generation))
            {
                Fail("meta.generation がありません");
                return false;
            }
            if (summaryJson == null)
            {
                Fail("summary.json（summaryJson）が入っていません。版の番号を突き合わせられません");
                return false;
            }
            string err;
            JsonValue sum = JsonValue.Parse(summaryJson.text, out err);
            if (sum == null || !sum.IsArray)
            {
                Fail("summary.json を読めません: " + err);
                return false;
            }
            string key = string.IsNullOrEmpty(layoutFileName)
                ? (layoutJson != null ? layoutJson.name : "")
                : layoutFileName;
            for (int i = 0; i < sum.Count; i++)
            {
                if (sum[i]["file"].S("") != key) continue;
                string g = sum[i]["generation"].S("");
                if (g != _generation)
                {
                    Fail("版の番号が summary.json と違います: この JSON " + _generation +
                         " / summary.json " + g + "（写し直してください）");
                    return false;
                }
                return true;
            }
            Fail("summary.json に \"" + key + "\" がありません（layoutFileName を確かめてください）");
            return false;
        }

        float Req(JsonValue obj, string key, string where)
        {
            JsonValue v = obj[key];
            if (v.kind != JsonValue.Kind.Number)
            {
                Fail(where + " がありません");
                return 0f;
            }
            return v.F(0f);
        }

        bool TryRect(JsonValue v, string where, out PlanRect rect)
        {
            rect = new PlanRect(0f, 0f, 0f, 0f);
            if (v.Count < 4)
            {
                Fail(where + " が [x0, y0, x1, y1] ではありません");
                return false;
            }
            rect = new PlanRect(v[0].F(0f), v[1].F(0f), v[2].F(0f), v[3].F(0f));
            return true;
        }

        bool TryPoint(JsonValue v, string where, out Vector3 p)
        {
            p = Vector3.zero;
            if (v.Count < 2)
            {
                Fail(where + " が [x, y] ではありません");
                return false;
            }
            p = new Vector3(v[0].F(0f), 0f, v[1].F(0f));
            return true;
        }

        static bool TryParseKind(string s, out PlaceKind kind)
        {
            kind = PlaceKind.Kitchen;
            if (string.IsNullOrEmpty(s)) return false;
            try
            {
                kind = (PlaceKind)Enum.Parse(typeof(PlaceKind), s, false);
                return true;
            }
            catch (Exception) { return false; }
        }

        /// <summary>部屋のラベルを PlaceKind に直す（依頼の対応表どおり）</summary>
        static bool TryRoomKind(string label, out PlaceKind kind)
        {
            kind = PlaceKind.Kitchen;
            if (string.IsNullOrEmpty(label)) return false;
            switch (label)
            {
                case "パントリー": kind = PlaceKind.Pantry; return true;
                case "キッチン": kind = PlaceKind.Kitchen; return true;
                case "冷蔵・冷凍・保存": kind = PlaceKind.Kitchen; return true;
                case "洗い場": kind = PlaceKind.Kitchen; return true;
                case "更衣室・社員トイレ": kind = PlaceKind.Office; return true;
                case "トイレ": kind = PlaceKind.Toilet; return true;
                case "待合": kind = PlaceKind.Waiting; return true;
                case "レジ": kind = PlaceKind.Register; return true;
                case "レジ内": kind = PlaceKind.RegisterBack; return true;
                case "ドリンクバー": kind = PlaceKind.DrinkBar; return true;
            }
            return false;
        }

        static bool TryTableKind(string type, out SeatKind kind)
        {
            kind = SeatKind.WallBooth;
            switch (type)
            {
                case "booth": kind = SeatKind.WallBooth; return true;
                case "t2": kind = SeatKind.WallTable; return true;
                case "booth6L": kind = SeatKind.LargeTable; return true;
            }
            return false;
        }

        static int NumberFromId(string id, int fallback)
        {
            if (string.IsNullOrEmpty(id)) return fallback;
            int n = 0; bool any = false;
            for (int i = 0; i < id.Length; i++)
            {
                char c = id[i];
                if (c < '0' || c > '9') continue;
                n = n * 10 + (c - '0');
                any = true;
            }
            return any ? n : fallback;
        }

        /// <summary>GameObject の名前に使える形にする（/ は階層になってしまう）</summary>
        static string Safe(string s)
        {
            if (string.IsNullOrEmpty(s)) return "x";
            return s.Replace('/', '_').Replace('\\', '_');
        }

        void Fail(string message)
        {
            _problems.Add(message);
        }

        static string F(float v)
        {
            return v.ToString("0.##", CultureInfo.InvariantCulture);
        }

        static string P(Vector3 v)
        {
            return "(" + F(v.x) + ", " + F(v.z) + ")";
        }
    }
}
```

### Assets/ServiceSim/Store/StorePlan.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>席の種類</summary>
    public enum SeatKind
    {
        WallBooth,    // 壁際ブース（両側ベンチ）
        WallTable,    // 壁沿いの卓（壁側ベンチ＋通路側に椅子）
        IslandTable,  // 島の卓（両側に椅子）
        Counter,      // カウンター席（今回は使わないが置ける）
        LargeTable    // 大人数席（今回は使わないが置ける）
    }

    /// <summary>場所の種類</summary>
    public enum PlaceKind
    {
        Kitchen,      // 厨房（通れない）
        Pantry,       // パントリー（水・おしぼり・カトラリー）
        FoodPass,     // 料理の受け渡し口
        DishReturn,   // 下げ膳の返却口
        Entrance,     // 入口
        Vestibule,    // 風除室
        Waiting,      // 待合
        Register,     // レジ
        Toilet,       // トイレ
        DrinkBar,     // ドリンクバー
        Office,       // 事務所・更衣室
        RobotStandby, // ロボットの待機場所（受け渡し口の前をふさがないため）B2
        DrinkBarStand,// ドリンクバーの前に立つ点（機械1台に1つ）B4
        DrinkBarQueue,// ドリンクバーの並ぶ点 B4
        RegisterQueue,// レジの前に並ぶ点（会計に立つ人が1人ずつ並ぶ）
        StaffStandby, // 手の空いたスタッフが待つ点（1人に1つ。太い通路の通り道をふさがない）
        RegisterBack  // レジの内側（スタッフだけが入れる。太い通路からだけ入れる）
    }

    /// <summary>箱の見た目（StoreBuilder がどの prefab を使うかを決める）</summary>
    public enum BoxStyle
    {
        Floor, Wall, Divider, Table, Bench, Chair, Counter, AisleMark,

        /// <summary>
        /// ベンチの座面の見た目（高さ 0.45 m）。当たりを持たないので
        /// NavMesh にも、前のセンサーにも、左右の空きの測りにも効かない。見た目だけ。
        /// </summary>
        BenchSeat,

        /// <summary>
        /// ベンチの背もたれの見た目（机と反対側のふちに薄く・高さ 1.1 m）。当たりなし。
        /// </summary>
        BenchBack,

        /// <summary>
        /// ベンチの当たりの箱（大きさ・位置・高さはベンチのまま、**見た目を持たない**）。
        /// NavMesh と前のセンサーはこの箱だけを見る。見た目は BenchSeat と BenchBack が受け持つ。
        /// </summary>
        BenchBody,

        /// <summary>
        /// ロボットが通らない所（robot_keepout。会計の列）の目印。床に置く色板で、
        /// 当たりを持たない。**人は通れる**ので、部屋や壁としては建てない。
        /// サイトの道順がもうこの中を通らないように作られているので、Unity は印だけ出す。
        /// </summary>
        KeepoutMark
    }

    /// <summary>XZ 平面の長方形（y は持たない）</summary>
    public struct PlanRect
    {
        public float xMin, xMax, zMin, zMax;

        public PlanRect(float xMin, float zMin, float xMax, float zMax)
        {
            this.xMin = Mathf.Min(xMin, xMax);
            this.xMax = Mathf.Max(xMin, xMax);
            this.zMin = Mathf.Min(zMin, zMax);
            this.zMax = Mathf.Max(zMin, zMax);
        }

        public static PlanRect FromSize(float xMin, float zMin, float sizeX, float sizeZ)
        {
            return new PlanRect(xMin, zMin, xMin + sizeX, zMin + sizeZ);
        }

        public static PlanRect FromCenter(Vector3 center, float sizeX, float sizeZ)
        {
            return new PlanRect(center.x - sizeX * 0.5f, center.z - sizeZ * 0.5f,
                                center.x + sizeX * 0.5f, center.z + sizeZ * 0.5f);
        }

        public float SizeX { get { return xMax - xMin; } }
        public float SizeZ { get { return zMax - zMin; } }
        public float CenterX { get { return (xMin + xMax) * 0.5f; } }
        public float CenterZ { get { return (zMin + zMax) * 0.5f; } }
        public Vector3 Center { get { return new Vector3(CenterX, 0f, CenterZ); } }
        public bool IsEmpty { get { return SizeX <= 0.0005f || SizeZ <= 0.0005f; } }

        public bool Contains(Vector3 p)
        {
            return p.x >= xMin && p.x <= xMax && p.z >= zMin && p.z <= zMax;
        }

        public PlanRect Expand(float m)
        {
            return new PlanRect(xMin - m, zMin - m, xMax + m, zMax + m);
        }

        public Bounds ToBounds(float yMin, float yMax)
        {
            Bounds b = new Bounds();
            b.SetMinMax(new Vector3(xMin, yMin, zMin), new Vector3(xMax, yMax, zMax));
            return b;
        }

        public override string ToString()
        {
            return "x " + xMin.ToString("0.##") + ".." + xMax.ToString("0.##") +
                   " / z " + zMin.ToString("0.##") + ".." + zMax.ToString("0.##");
        }
    }

    /// <summary>置く箱1つ</summary>
    public class PlanBox
    {
        public string name;
        public BoxStyle style;
        public PlanRect rect;
        public float height;
        /// <summary>下の面の高さ（m）。ふつうは 0</summary>
        public float baseY;

        public PlanBox(string name, BoxStyle style, PlanRect rect, float height)
        {
            this.name = name;
            this.style = style;
            this.rect = rect;
            this.height = height;
            this.baseY = 0f;
        }
    }

    /// <summary>椅子1脚（引けるものと引けないもの）</summary>
    public class PlanChair
    {
        public PlanRect rect;
        public float height;
        /// <summary>引けるか。ブースのベンチは false</summary>
        public bool pullable;
        /// <summary>引く向き（通路の側へ）。XZ の単位ベクトル</summary>
        public Vector3 pullDirection;
    }

    /// <summary>
    /// 座る場所1つぶんの「人が立つ点」。
    /// 通路からここまで人が歩けるかを Build Store が確かめる（席に入れない卓を見つけるため）。
    /// </summary>
    public struct PlanSeatSpot
    {
        /// <summary>どの席か（報告に出す。例「壁側」「通路側」「南ベンチ」）</summary>
        public string name;
        /// <summary>その席に着くために人が立つ点</summary>
        public Vector3 point;

        public PlanSeatSpot(string name, Vector3 point)
        {
            this.name = name;
            this.point = point;
        }
    }

    /// <summary>卓1つ</summary>
    public class PlanSeat
    {
        public int tableId;
        public SeatKind kind;
        public PlanRect tableRect;
        public float tableHeight;

        /// <summary>何人かけの卓か（2人席・4人席）。組の座らせ方に使う</summary>
        public int seatCount = 4;

        /// <summary>ロボットが料理を置くために止まる点（通路の上）</summary>
        public Vector3 deliveryPoint;
        /// <summary>この卓に行くときに使う細い通路の番号（譲り合いの区画に使う）</summary>
        public int aisleIndex = -1;
        public readonly List<PlanChair> chairs = new List<PlanChair>();

        /// <summary>席ごとの「人が立つ点」（Build Store の席への入りやすさの確かめに使う）</summary>
        public readonly List<PlanSeatSpot> spots = new List<PlanSeatSpot>();

        /// <summary>
        /// 人が実際に座る位置（座面・椅子の真ん中）。seatCount 個ある。
        /// 座っている客の形を出す場所に使う（見た目だけ。当たりも NavMesh も持たない）。
        /// </summary>
        public readonly List<Vector3> seatPoints = new List<Vector3>();
    }

    /// <summary>通路1本（譲り合いの区画と、入る前に待つ点）</summary>
    public class PlanAisle
    {
        public string name;
        public PlanRect rect;
        /// <summary>細い通路か（幅を切り替える対象か）</summary>
        public bool narrow;

        // v5b: 「通りにくい通路」の印（hardToPass）はやめた。
        // 狭い道を使うかどうかは、サイトの routes の use（normal / narrow）が正。
        /// <summary>この通路に入る前に待つ点</summary>
        public Vector3 entrance;

        public Bounds Zone(float yMin, float yMax) { return rect.ToBounds(yMin, yMax); }
    }

    /// <summary>場所1つ（厨房・パントリー・受け渡し口など）</summary>
    public class PlanPlace
    {
        public PlaceKind kind;
        public string name;
        public PlanRect rect;
        public float height;
        /// <summary>人が立つ・ロボットが止まる点</summary>
        public Vector3 point;
        /// <summary>通れない箱として置くか</summary>
        public bool solid;
    }

    /// <summary>
    /// 店の「部品をどこに置くか」の一覧。
    /// StoreLayout（の種類ごとの実装）がこれを作り、StoreBuilder がこれだけを見て並べる。
    /// StoreBuilder は格子や帯の組み方を知らない。
    /// </summary>
    public class StorePlan
    {
        /// <summary>床の全体の広がり（カメラの画角とベイク範囲に使う）</summary>
        public PlanRect floor;

        /// <summary>
        /// 実際に床を敷く長方形。建物の外に床が出ないように分けて持つ。
        /// 空なら floor を1枚だけ敷く。
        /// </summary>
        public readonly List<PlanRect> floorPieces = new List<PlanRect>();

        public float wallHeight = 2.4f;
        public float wallThickness = 0.15f;
        public float floorThickness = 0.2f;

        /// <summary>この plan を作ったときの細い通路の幅（m）</summary>
        public float aisleWidth;

        /// <summary>
        /// 人がロボットをよけるときに下がれる場所（通路の外）。
        /// 壁ぞいの卓と卓のすき間など。店の形が入れる。
        /// </summary>
        public readonly List<Vector3> refuges = new List<Vector3>();

        /// <summary>
        /// ロボットの道順（v5b）。サイトが決めた曲がり角の点の並び。
        /// 無ければ null（そのときは今までどおり Unity の道探しで行き先まで行く）。
        /// </summary>
        public RouteBook routes;

        /// <summary>
        /// ロボットが走れる所の地図（依頼 v9・サイトの {名前}_map.json）。
        /// MapPlanner のときは、ロボットがこの地図の上で自分の道を引く。
        /// 無ければ null（そのときは MapPlanner では走れない）。
        /// </summary>
        public RobotMap robotMap;

        public readonly List<PlanBox> boxes = new List<PlanBox>();
        public readonly List<PlanSeat> seats = new List<PlanSeat>();
        public readonly List<PlanAisle> aisles = new List<PlanAisle>();
        public readonly List<PlanPlace> places = new List<PlanPlace>();

        // ---- 組み立てを助ける小さな道具 ----

        /// <summary>床を1枚足す。floor の広がりも合わせて広げる</summary>
        public void AddFloor(PlanRect rect)
        {
            if (rect.IsEmpty) return;
            if (floorPieces.Count == 0) floor = rect;
            else
            {
                floor = new PlanRect(
                    Mathf.Min(floor.xMin, rect.xMin), Mathf.Min(floor.zMin, rect.zMin),
                    Mathf.Max(floor.xMax, rect.xMax), Mathf.Max(floor.zMax, rect.zMax));
            }
            floorPieces.Add(rect);
        }

        public PlanBox AddBox(string name, BoxStyle style, PlanRect rect, float height)
        {
            if (rect.IsEmpty || height <= 0.0005f) return null;
            PlanBox b = new PlanBox(name, style, rect, height);
            boxes.Add(b);
            return b;
        }

        public PlanAisle AddAisle(string name, PlanRect rect, bool narrow, Vector3 entrance)
        {
            PlanAisle a = new PlanAisle();
            a.name = name;
            a.rect = rect;
            a.narrow = narrow;
            a.entrance = entrance;
            aisles.Add(a);
            return a;
        }

        public PlanPlace AddPlace(PlaceKind kind, string name, PlanRect rect, float height, Vector3 point, bool solid)
        {
            PlanPlace p = new PlanPlace();
            p.kind = kind;
            p.name = name;
            p.rect = rect;
            p.height = height;
            p.point = point;
            p.solid = solid;
            places.Add(p);
            return p;
        }

        /// <summary>場所の点を引く。無ければ fallback</summary>
        public Vector3 PointOf(PlaceKind kind, Vector3 fallback)
        {
            for (int i = 0; i < places.Count; i++)
                if (places[i].kind == kind) return places[i].point;
            return fallback;
        }

        /// <summary>
        /// 同じ種類の場所の点をすべて集める（出てきた順）。
        /// ロボットの待機場所やドリンクバーの立つ点のように、1種類に点がいくつもあるときに使う。
        /// </summary>
        public List<Vector3> PointsOf(PlaceKind kind)
        {
            List<Vector3> list = new List<Vector3>();
            for (int i = 0; i < places.Count; i++)
                if (places[i].kind == kind) list.Add(places[i].point);
            return list;
        }

        public bool HasPlace(PlaceKind kind)
        {
            for (int i = 0; i < places.Count; i++)
                if (places[i].kind == kind) return true;
            return false;
        }

        public PlanRect RectOf(PlaceKind kind)
        {
            for (int i = 0; i < places.Count; i++)
                if (places[i].kind == kind) return places[i].rect;
            return new PlanRect(0f, 0f, 0f, 0f);
        }

        public PlanAisle AisleAt(int index)
        {
            if (index < 0 || index >= aisles.Count) return null;
            return aisles[index];
        }

        /// <summary>細い通路の数</summary>
        public int NarrowAisleCount
        {
            get
            {
                int n = 0;
                for (int i = 0; i < aisles.Count; i++) if (aisles[i].narrow) n++;
                return n;
            }
        }

        public Bounds NavBounds(float padding)
        {
            PlanRect r = floor.Expand(padding);
            return r.ToBounds(-1f, wallHeight + 1f);
        }
    }
}
```

### Assets/ServiceSim/Store/StoreBuilder.cs

```csharp
using System;
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// StoreLayout が作った部品の一覧（StorePlan）を見て、床・壁・卓・椅子・ベンチ・
    /// カウンター・通路の色板を並べる。
    ///
    /// この script は店だけを扱う。ロボットと人は動かさない。
    /// 格子や帯の組み方は知らない（StorePlan だけを見る）。
    /// </summary>
    public class StoreBuilder : MonoBehaviour
    {
        [Serializable]
        public class TableInfo
        {
            public int tableId;
            public SeatKind kind;
            public Transform root;
            public Vector3 center;
            public Vector3 deliveryPoint;   // ロボットが止まる位置（通路の上）
            public Vector3 aisleEntrance;   // その通路に入る前に待つ位置
            public Bounds aisleZone;        // 譲り合いの区画
            public int aisleIndex = -1;

            /// <summary>何人かけの卓か（2人席・4人席）</summary>
            public int seatCount = 4;

            public List<Chair> aisleChairs = new List<Chair>();

            /// <summary>席ごとの「人が立つ点」（席に入れるかの確かめに使う）</summary>
            public List<PlanSeatSpot> spots = new List<PlanSeatSpot>();

            /// <summary>人が実際に座る位置（座っている客の形を出す場所）</summary>
            public List<Vector3> seatPoints = new List<Vector3>();
        }

        [Serializable]
        public class AisleInfo
        {
            public string name;
            public bool narrow;
            public Bounds zone;
            public Vector3 entrance;
            public float width;
        }

        [Header("設定（数字はすべてこの .asset の中）")]
        public StoreLayout layout;

        [Header("部品")]
        public GameObject floorPrefab;
        public GameObject wallPrefab;
        public GameObject benchPrefab;       // ベンチと仕切り（高さ 1.1 m のもの）
        public GameObject benchSeatPrefab;   // ベンチの座面の見た目（当たりなし）
        public GameObject benchBackPrefab;   // ベンチの背もたれの見た目（当たりなし）
        public GameObject benchBodyPrefab;   // ベンチの当たりの箱（見た目なし。NavMesh とセンサーはこれだけを見る）
        public GameObject tablePrefab;
        public GameObject chairPrefab;
        public GameObject kitchenPassPrefab; // カウンター類
        public GameObject aisleMarkPrefab;   // 通路の色板（当たりなし）
        public GameObject keepoutMarkPrefab; // ロボットが通らない所の色板（当たりなし。人は通れる）

        [Header("床の塗り分け（見た目だけ）")]
        [Tooltip("ロボットがふだん走る床の色（水色）")]
        public Material robotFloorMaterial;

        [Tooltip("細い通路（狭い道で入る）の色（濃い水色）")]
        public Material peopleFloorMaterial;

        [Header("組み立てた結果（読むだけ）")]
        [SerializeField] List<TableInfo> _tables = new List<TableInfo>();
        [SerializeField] List<AisleInfo> _aisles = new List<AisleInfo>();
        [SerializeField] float _builtAisleWidth;
        [SerializeField] Vector2 _outerSize;

        readonly Dictionary<PlaceKind, Vector3> _points = new Dictionary<PlaceKind, Vector3>();

        /// <summary>
        /// 同じ種類に点がいくつもある場所（ロボットの待機場所・ドリンクバーの立つ点など）。
        /// _points には最初の1つしか入らないので、別に全部を持つ。
        /// </summary>
        readonly Dictionary<PlaceKind, List<Vector3>> _pointLists =
            new Dictionary<PlaceKind, List<Vector3>>();
        readonly List<Transform> _notWalkableRoots = new List<Transform>();
        StorePlan _plan;

        /// <summary>
        /// 上面を歩ける場所にしてはいけないものの親（壁・ベンチ・仕切り・卓・椅子・カウンター）。
        /// NavMeshBaker がここを Not Walkable にして焼く。
        /// </summary>
        public IList<Transform> NotWalkableRoots { get { return _notWalkableRoots; } }

        public IList<TableInfo> Tables { get { return _tables; } }
        public IList<AisleInfo> Aisles { get { return _aisles; } }
        public float BuiltAisleWidth { get { return _builtAisleWidth; } }
        public Vector2 OuterSize { get { return _outerSize; } }
        public StorePlan Plan { get { return _plan; } }

        /// <summary>ロボットの道順（v5b。サイトが決めた曲がり角の点）。無ければ null</summary>
        public RouteBook Routes { get { return _plan != null ? _plan.routes : null; } }

        /// <summary>
        /// ロボットが走れる所の地図（依頼 v9）。無ければ null。
        /// MapPlanner のロボットはこの上で自分の道を引く
        /// </summary>
        public RobotMap RobotMap { get { return _plan != null ? _plan.robotMap : null; } }

        /// <summary>場所の点。無ければ原点</summary>
        public Vector3 PointOf(PlaceKind kind)
        {
            Vector3 p;
            if (_points.TryGetValue(kind, out p)) return p;
            return Vector3.zero;
        }

        public bool HasPoint(PlaceKind kind) { return _points.ContainsKey(kind); }

        /// <summary>その種類の場所の点をすべて返す（出てきた順）。無ければ空</summary>
        public IList<Vector3> PointsOf(PlaceKind kind)
        {
            List<Vector3> list;
            if (_pointLists.TryGetValue(kind, out list)) return list;
            return new List<Vector3>();
        }

        // ロボットが使う点
        public Vector3 PassPoint { get { return PointOf(PlaceKind.FoodPass); } }
        public Vector3 ReturnPoint { get { return PointOf(PlaceKind.DishReturn); } }
        public Vector3 PantryPoint { get { return PointOf(PlaceKind.Pantry); } }

        /// <summary>ロボットの待機場所（B2）。受け渡し口の前とは別の点</summary>
        public IList<Vector3> RobotStandbyPoints { get { return PointsOf(PlaceKind.RobotStandby); } }

        // 人が使う点
        public Vector3 RegisterPoint { get { return PointOf(PlaceKind.Register); } }
        public Vector3 ToiletPoint { get { return PointOf(PlaceKind.Toilet); } }
        public Vector3 DrinkBarPoint { get { return PointOf(PlaceKind.DrinkBar); } }
        public Vector3 EntrancePoint { get { return PointOf(PlaceKind.Entrance); } }
        public Vector3 WaitingPoint { get { return PointOf(PlaceKind.Waiting); } }

        /// <summary>レジの前に並ぶ点（会計に立つ人が1人ずつ並ぶ）</summary>
        public IList<Vector3> RegisterQueuePoints { get { return PointsOf(PlaceKind.RegisterQueue); } }

        /// <summary>手の空いたスタッフが待つ点（1人に1つ）</summary>
        public IList<Vector3> StaffStandbyPoints { get { return PointsOf(PlaceKind.StaffStandby); } }

        /// <summary>キッチンの部屋の広がり（キッチンのモニターを置くのに使う）。無ければ空</summary>
        public PlanRect KitchenRect
        {
            get { return _plan != null ? _plan.RectOf(PlaceKind.Kitchen) : new PlanRect(0f, 0f, 0f, 0f); }
        }

        /// <summary>レジの内側（店員側）の広がり。無ければ空</summary>
        public PlanRect RegisterBackRect
        {
            get { return _plan != null ? _plan.RectOf(PlaceKind.RegisterBack) : new PlanRect(0f, 0f, 0f, 0f); }
        }

        /// <summary>レジの内側（スタッフが会計に立つ点）。無ければレジの前の点</summary>
        public Vector3 RegisterBackPoint
        {
            get { return HasPoint(PlaceKind.RegisterBack) ? PointOf(PlaceKind.RegisterBack) : RegisterPoint; }
        }

        /// <summary>待合の広さ（待つ客を散らすのに使う）</summary>
        public Bounds WaitingArea { get; private set; }

        readonly List<Vector3> _refuges = new List<Vector3>();

        /// <summary>人がロボットをよけて下がれる場所（通路の外）。壁ぞいの卓のすき間など</summary>
        public IList<Vector3> Refuges { get { return _refuges; } }

        /// <summary>床の塗り分けを入れる親（当たりなし。NavMesh には焼かれない）</summary>
        public Transform FloorPaintRoot
        {
            get
            {
                Transform t = transform.Find("FloorPaint");
                if (t == null)
                {
                    GameObject go = new GameObject("FloorPaint");
                    go.transform.SetParent(transform, false);
                    t = go.transform;
                }
                return t;
            }
        }

        /// <summary>
        /// 床を塗り分け直す（見た目だけ。2026-10-23 に決め直した）。
        /// 水色＝ロボットがふだん走る床（routes の use=normal）、
        /// 濃い水色＝細い通路（aisles の区画と use=narrow の道）。
        /// サイトの JSON から決めるので、NavMesh の形は見ない。NavMesh も当たりも変えない。
        /// personRadius はもう使わない（呼ぶ側をそのままにするため残してある）。
        /// </summary>
        public FloorPaint.Result PaintFloor(NavMeshBaker baker, float personRadius)
        {
            FloorPaint.Result res = new FloorPaint.Result();
            if (layout == null || _plan == null) return res;

            Transform root = FloorPaintRoot;
            if (!layout.showFloorPaint) { FloorPaint.Clear(root); return res; }

            return FloorPaint.Rebuild(root, layout, _plan.NavBounds(layout.navBoundsPadding),
                                      _plan.routes, _plan, layout.navAgentRadius,
                                      robotFloorMaterial, peopleFloorMaterial);
        }

        /// <summary>
        /// その点がある場所の名前（レジ・待合・ドリンクバーなど）。どこでもなければ空。
        /// 記録で「どこで起きたか」を通路の名前だけでなく場所の名前でも言うために使う。
        /// </summary>
        public string PlaceNameAt(Vector3 p)
        {
            if (_plan == null) return "";
            for (int i = 0; i < _plan.places.Count; i++)
            {
                PlanPlace pl = _plan.places[i];
                if (pl.rect.IsEmpty) continue;
                if (pl.rect.Contains(p)) return PlaceName(pl.kind);
            }
            return "";
        }

        /// <summary>場所の種類の名前（記録に出す）</summary>
        public static string PlaceName(PlaceKind kind)
        {
            switch (kind)
            {
                case PlaceKind.Kitchen: return "厨房";
                case PlaceKind.Pantry: return "パントリー";
                case PlaceKind.FoodPass: return "受け渡し口";
                case PlaceKind.DishReturn: return "返却口";
                case PlaceKind.Entrance: return "入口";
                case PlaceKind.Vestibule: return "風除室";
                case PlaceKind.Waiting: return "待合";
                case PlaceKind.Register: return "レジ";
                case PlaceKind.Toilet: return "トイレ";
                case PlaceKind.DrinkBar: return "ドリンクバー";
                case PlaceKind.Office: return "事務所";
                case PlaceKind.RobotStandby: return "ロボット待機";
                case PlaceKind.DrinkBarStand: return "ドリンクバーの前";
                case PlaceKind.DrinkBarQueue: return "ドリンクバーの列";
                case PlaceKind.RegisterQueue: return "レジの列";
                case PlaceKind.StaffStandby: return "スタッフ待機";
                case PlaceKind.RegisterBack: return "レジの内側";
            }
            return "";
        }

        /// <summary>いまの layout.aisleWidthIndex で店を組み立て直す</summary>
        public void Build()
        {
            if (layout == null)
            {
                Debug.LogError("[ServiceSim] StoreBuilder に StoreLayout が入っていません");
                return;
            }

            Clear();

            _plan = layout.NewPlan();
            if (_plan == null || _plan.seats.Count == 0)
            {
                Debug.LogError("[ServiceSim] StoreLayout が部品の一覧を作れませんでした（席が0）");
                return;
            }

            _builtAisleWidth = _plan.aisleWidth;
            _outerSize = new Vector2(_plan.floor.SizeX, _plan.floor.SizeZ);

            BuildFloor(_plan);
            BuildBoxes(_plan);
            BuildPlaces(_plan);
            BuildSeats(_plan);
            BuildAisles(_plan);
            BuildAisleMarks(_plan);
            _refuges.AddRange(_plan.refuges);
        }

        /// <summary>組み立てたものを消す</summary>
        public void Clear()
        {
            _tables.Clear();
            _aisles.Clear();
            _points.Clear();
            _pointLists.Clear();
            _refuges.Clear();
            _notWalkableRoots.Clear();
            _plan = null;
            WaitingArea = new Bounds();
            for (int i = transform.childCount - 1; i >= 0; i--)
                DestroyChild(transform.GetChild(i).gameObject);
        }

        /// <summary>細い通路の幅を選び直して組み立て直す</summary>
        public void SetAisleWidthIndex(int index)
        {
            if (layout == null) return;
            layout.aisleWidthIndex = index;
            Build();
        }

        // ---------------- 床 ----------------

        void BuildFloor(StorePlan plan)
        {
            Transform g = NewGroup("Floor", true);

            if (plan.floorPieces.Count == 0)
            {
                PlaceFloor(g, plan, plan.floor, "Floor");
                return;
            }
            for (int i = 0; i < plan.floorPieces.Count; i++)
                PlaceFloor(g, plan, plan.floorPieces[i], "Floor" + (i + 1).ToString("00"));
        }

        void PlaceFloor(Transform parent, StorePlan plan, PlanRect r, string name)
        {
            GameObject floor = Box(floorPrefab, parent, name, r, plan.floorThickness);
            if (floor != null)
                floor.transform.position = new Vector3(r.CenterX, -plan.floorThickness * 0.5f, r.CenterZ);
        }

        // ---------------- 箱（壁・仕切り・ベンチ）----------------

        void BuildBoxes(StorePlan plan)
        {
            Transform walls = NewGroup("Walls", false);
            Transform seats = NewGroup("SeatStructure", false);

            // 座る場所の見た目は、当たりを持たない prefab でしか置かない。
            // 当たりのある prefab で代わりに置くと NavMesh が変わってしまうため、
            // prefab が無いときは置かずに知らせる（Setup を実行すれば作られる）。
            int skippedSeatLooks = 0;

            for (int i = 0; i < plan.boxes.Count; i++)
            {
                PlanBox b = plan.boxes[i];

                // 見た目だけの箱は、当たりを持たない prefab でしか置かない。
                // 当たりのある prefab で代えると NavMesh が変わってしまう
                bool lookOnly = b.style == BoxStyle.BenchSeat || b.style == BoxStyle.BenchBack ||
                                b.style == BoxStyle.KeepoutMark;
                if (lookOnly && PrefabFor(b.style) == null) { skippedSeatLooks++; continue; }

                Transform parent = (b.style == BoxStyle.Bench || b.style == BoxStyle.Divider ||
                                    b.style == BoxStyle.BenchBody || lookOnly) ? seats : walls;
                GameObject go = Box(PrefabFor(b.style), parent, b.name, b.rect, b.height);
                if (go == null) continue;
                if (b.baseY != 0f) go.transform.position += Vector3.up * b.baseY;
                SimTags.Apply(go, TagFor(b.style));
            }

            if (skippedSeatLooks > 0)
                Debug.LogWarning("[ServiceSim] ベンチの見た目（座面・背もたれ）" + skippedSeatLooks +
                                 " 個を置きませんでした（BenchSeat.prefab / BenchBack.prefab がありません）。" +
                                 "ServiceSim/Setup を実行してください。" +
                                 "当たりの箱はそのまま置いているので、測る数字は変わりません");
        }

        /// <summary>
        /// 種類の印（Tag）。あとで映像から見分ける学習に使う。いまは付けるだけ（v5）。
        /// Tag は物理にも NavMesh にも効かないので、測る数字は変わらない
        /// </summary>
        static string TagFor(BoxStyle style)
        {
            switch (style)
            {
                case BoxStyle.Bench:
                case BoxStyle.BenchBody:
                case BoxStyle.BenchSeat:
                case BoxStyle.BenchBack: return SimTags.Bench;
                case BoxStyle.Table: return SimTags.Table;
                case BoxStyle.Chair: return SimTags.Chair;
                default: return null;
            }
        }

        // ---------------- 場所 ----------------

        void BuildPlaces(StorePlan plan)
        {
            Transform g = NewGroup("Places", false);

            for (int i = 0; i < plan.places.Count; i++)
            {
                PlanPlace p = plan.places[i];
                if (p.solid && p.height > 0.0005f)
                    Box(PrefabFor(p.kind), g, p.name, p.rect, p.height);

                NewChild(g, p.name + "_Point").position = p.point;
                if (!_points.ContainsKey(p.kind)) _points.Add(p.kind, p.point);

                List<Vector3> list;
                if (!_pointLists.TryGetValue(p.kind, out list))
                {
                    list = new List<Vector3>();
                    _pointLists.Add(p.kind, list);
                }
                list.Add(p.point);

                if (p.kind == PlaceKind.Waiting) WaitingArea = p.rect.ToBounds(0f, 2f);
            }
        }

        // ---------------- 席 ----------------

        void BuildSeats(StorePlan plan)
        {
            Transform g = NewGroup("Seats", false);

            for (int i = 0; i < plan.seats.Count; i++)
            {
                PlanSeat s = plan.seats[i];
                Transform unit = NewChild(g, "Table" + s.tableId.ToString("00"));

                Box(tablePrefab, unit, "TableTop", s.tableRect, s.tableHeight);

                TableInfo info = new TableInfo();
                info.tableId = s.tableId;
                info.kind = s.kind;
                info.root = unit;
                info.center = s.tableRect.Center;
                info.deliveryPoint = s.deliveryPoint;
                info.aisleIndex = s.aisleIndex;
                info.seatCount = s.seatCount;
                info.spots.AddRange(s.spots);
                info.seatPoints.AddRange(s.seatPoints);

                PlanAisle aisle = plan.AisleAt(s.aisleIndex);
                if (aisle != null)
                {
                    info.aisleZone = aisle.Zone(-1f, 2f);
                    info.aisleEntrance = aisle.entrance;
                }
                else
                {
                    info.aisleEntrance = s.deliveryPoint;
                }

                for (int c = 0; c < s.chairs.Count; c++)
                {
                    PlanChair pc = s.chairs[c];
                    GameObject go = Box(chairPrefab, unit, "Chair", pc.rect, pc.height);
                    if (go == null) continue;
                    Chair chair = go.GetComponent<Chair>();
                    if (chair == null) continue;
                    chair.isAisleSide = pc.pullable;
                    chair.pullDirection = pc.pullDirection;
                    chair.MarkParked();
                    if (pc.pullable) info.aisleChairs.Add(chair);
                }

                _tables.Add(info);
            }
        }

        // ---------------- 通路 ----------------

        void BuildAisles(StorePlan plan)
        {
            for (int i = 0; i < plan.aisles.Count; i++)
            {
                PlanAisle a = plan.aisles[i];
                AisleInfo info = new AisleInfo();
                info.name = a.name;
                info.narrow = a.narrow;
                info.zone = a.Zone(-1f, 2f);
                info.entrance = a.entrance;
                info.width = Mathf.Min(a.rect.SizeX, a.rect.SizeZ);
                _aisles.Add(info);
            }
        }

        /// <summary>
        /// 通路の床に色の板を敷く。当たりは持たないので NavMesh には焼かれず、計測には効かない。
        /// </summary>
        void BuildAisleMarks(StorePlan plan)
        {
            if (!layout.showAisleMarks || aisleMarkPrefab == null) return;

            Transform g = NewGroup("AisleMarks", false);
            for (int i = 0; i < plan.aisles.Count; i++)
            {
                PlanAisle a = plan.aisles[i];
                Box(aisleMarkPrefab, g, "Mark_" + a.name, a.rect, layout.aisleMarkThickness);
            }
        }

        // ---------------- 小さな道具 ----------------

        GameObject PrefabFor(BoxStyle style)
        {
            switch (style)
            {
                case BoxStyle.Floor: return floorPrefab;
                case BoxStyle.Table: return tablePrefab;
                case BoxStyle.Chair: return chairPrefab;
                case BoxStyle.Bench: return benchPrefab != null ? benchPrefab : wallPrefab;
                case BoxStyle.Divider: return benchPrefab != null ? benchPrefab : wallPrefab;
                // 当たりの箱（見た目なし）。prefab が無いときは、これまでどおり見える箱で代える
                // （当たりの大きさは同じなので、NavMesh と測る数字は変わらない）
                case BoxStyle.BenchBody:
                    return benchBodyPrefab != null ? benchBodyPrefab
                         : (benchPrefab != null ? benchPrefab : wallPrefab);
                case BoxStyle.BenchSeat: return benchSeatPrefab;   // 無いときは BuildBoxes が置かない
                case BoxStyle.BenchBack: return benchBackPrefab;   // 同じく
                case BoxStyle.Counter: return kitchenPassPrefab;
                case BoxStyle.AisleMark: return aisleMarkPrefab;
                // ロボットが通らない所の目印。無ければ通路の色板で代える（どちらも当たりなし）
                case BoxStyle.KeepoutMark:
                    return keepoutMarkPrefab != null ? keepoutMarkPrefab : aisleMarkPrefab;
                default: return wallPrefab;
            }
        }

        GameObject PrefabFor(PlaceKind kind)
        {
            switch (kind)
            {
                case PlaceKind.Kitchen:
                case PlaceKind.Office:
                    return wallPrefab;
                default:
                    return kitchenPassPrefab;
            }
        }

        /// <summary>
        /// 組み立てたものを入れる親を作る。
        /// walkable が false の親は、NavMesh で Not Walkable として焼く（上面に乗れないように）。
        /// </summary>
        Transform NewGroup(string name, bool walkable)
        {
            Transform t = NewChild(transform, name);
            if (!walkable) _notWalkableRoots.Add(t);
            return t;
        }

        Transform NewChild(Transform parent, string name)
        {
            GameObject go = new GameObject(name);
            go.transform.SetParent(parent, false);
            return go.transform;
        }

        /// <summary>長方形と高さから箱を1つ置く（下の面が y=0、上の面が y=height）</summary>
        GameObject Box(GameObject prefab, Transform parent, string name, PlanRect rect, float height)
        {
            if (rect.IsEmpty || height <= 0.0005f) return null;

            GameObject go;
            if (prefab != null)
            {
                go = Instantiate(prefab, parent);
            }
            else
            {
                go = GameObject.CreatePrimitive(PrimitiveType.Cube);
                go.transform.SetParent(parent, false);
            }
            go.name = name;
            go.transform.localScale = new Vector3(rect.SizeX, height, rect.SizeZ);
            go.transform.position = new Vector3(rect.CenterX, height * 0.5f, rect.CenterZ);
            return go;
        }

        static void DestroyChild(GameObject go)
        {
            if (Application.isPlaying) Destroy(go);
            else DestroyImmediate(go);
        }
    }
}
```

### 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));
        }

        // ---------------- 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;

            /// <summary>回し始めの Unity Version Control の changeset（段階B。メニューが入れる）</summary>
            public string unityCs = "";

            /// <summary>
            /// 回し始めに保留中の変更があったか（段階B）。
            /// あれば「保留あり N 件」。status に関係なく残す（依頼 v7 の 2）
            /// </summary>
            public string pendingNote = "";

            /// <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");
            sb.Append("  \"unity_cs\": ").Append(Str(info.unityCs)).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;

            /// <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>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;
        }

        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。" +
                "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(", ");
                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");
                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 を含む）
        /// </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(", ");
            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");

            // サイトの試算の答え（並べる表のもと）
            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/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;

        [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>計測が始まってからこの回数走っても記録が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>この条件ぶんを書き出したか（途中で止めたときに二重に書かないため。v5 3章）</summary>
        bool _outputsWritten;

        public bool Running { get { return _running; } }

        /// <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 が出す）
            if (plan.disableRenderingWhileRunning && !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();
            _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;
                monitor.show = plan.showKitchenMonitor;
            }

            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;
            }

            groups.Setup(job.crowd, job.seed, people.Targets);
            ops.SetStaffCount(people.StaffSpawned);

            _timeOffset = Time.time;
            _lastLog = 0f;
            _jobRunning = true;

            // 回し始めの行に、Unity だけの決まりの切り替えを必ず出す
            string how = "\n  人のよけ方: " +
                         (people.spec != null && people.spec.staffFirstYield ? "店員優先の版" : "今のよけ方") +
                         " / 同じ通路に2台を集めない: " +
                         (robotSpec != null && robotSpec.oneRobotPerAisle ? "入れた" : "入れない") +
                         " / 戻りの線: " +
                         (robotSpec != null && robotSpec.useReturnLine ? "使う" : "使わない") +
                         " / キッチンのモニター: " + (plan.showKitchenMonitor ? "出す" : "出さない");

            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()
        {
            _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);
            _simMinutes = ops.NowS / 60f;

            if (ops.NowS >= _measureStartS) _walkerSamples.Add(groups.WalkingBodies + people.StaffSpawned);

            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;
            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;
            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);
                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;
                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));
                }
                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;
                _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);
                _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;
                _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();
                }
                // 実際に使った決め方（地図が無ければ 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() + " 回" +
                          "（使えないときは受け渡し口の前へ戻る）" +
                          "\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);
            }
        }

        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();
            Time.captureDeltaTime = 0f;
            if (_running) Time.timeScale = 1f;
        }
    }
}
```

### Assets/ServiceSim/Store/KitchenMonitor.cs

```csharp
using System.Collections.Generic;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// キッチンのモニター（2026-10-23 エイイチと決めた。設計書 v0 の 0 章）。
    /// ロボット・スタッフのいまの仕事と止まりの理由、店の様子を1つにまとめて出す。
    ///
    /// 出し方は **Scene ビューの画面に貼り付けるパネル1つ**（描くのは ServiceSimLabels）。
    /// カメラを回してもズームしても、画面の上の位置は変わらない。
    /// 屋根の上に板を置く作り・店の上に浮かべる吹き出しは、どちらもやめた
    /// （読めなくなる／通路やほかの札に重なるため）。
    ///
    /// この script は **文を作るだけ**で、見た目は持たない。
    ///   ・当たり判定・NavMesh・ロボットのセンサー・人がよける処理には入らない
    ///   ・言葉と数字は、記録に使っているものだけ（LABEL_RULES.md の表）
    ///   ・文を作り直すのは店の中の 0.5 秒ごと（毎コマ作らない）
    ///   ・頭の上の字幕は測る回では切る（B11）が、このパネル1つだけは出す
    /// </summary>
    public class KitchenMonitor : MonoBehaviour
    {
        [Header("参照（Setup が入れる）")]
        public StoreBuilder store;
        public ServiceOps ops;
        public GroupManager groups;
        public PeopleSpawner people;

        [Header("出し方")]
        [Tooltip("出すか。ExperimentPlan.showKitchenMonitor が入れる")]
        public bool show = true;

        [Tooltip("文を作り直す間隔（店の中の秒）。毎コマ作らない")]
        public float refreshSec = 0.5f;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _lines;

        float _nextAtS;
        string _text = "";
        readonly StringBuilder _sb = new StringBuilder();

        /// <summary>この部品を置く親の名前</summary>
        public const string RootName = "KitchenMonitor";

        /// <summary>いま出す文（ServiceSimLabels が読む）。空なら出さない</summary>
        public string Text { get { return _text; } }

        /// <summary>行の数（読むだけ）</summary>
        public int Lines { get { return _lines; } }

        void Update()
        {
            if (!show) { _text = ""; _lines = 0; return; }
            if (ops == null) return;      // 回していないあいだは見本を出す（描く側が決める）

            float now = ops.NowS;
            if (now < _nextAtS) return;
            _nextAtS = now + Mathf.Max(0.05f, refreshSec);
            _text = Compose();
            _lines = CountLines(_text);
        }

        /// <summary>
        /// 店を建てたあと、まだ回していないときに出す見本（ServiceSimLabels が読む）。
        /// 場所と大きさを確かめられるようにするため
        /// </summary>
        public static string SampleText()
        {
            return "ロボット×2\n" +
                   "  1号機 配膳 卓19 ／ 一時停止：人が立ち止まっていた\n" +
                   "  2号機 戻る ／ よける：相手を待っている\n" +
                   "スタッフ×4  案内 1／配膳 1／片付け 1／待機 1\n" +
                   "ロボット待ち スタッフ 0 人 ／ 客 1 人（回り道 0 人）\n" +
                   "組 0（席待ち 0・帰った 0） 皿 0\n" +
                   "受け渡し口でロボットを待っている皿 0\n" +
                   "あきらめた 0 ／ 移した 0 ／ 卓を空けた 0\n" +
                   "立ち往生 0 回　　※見本（回すと本物の数字になります）";
        }

        static int CountLines(string s)
        {
            if (string.IsNullOrEmpty(s)) return 0;
            int n = 1;
            for (int i = 0; i < s.Length; i++) if (s[i] == '\n') n++;
            return n;
        }

        /// <summary>
        /// 前の作り（屋根の上の板）で置いた子を片付ける。
        /// 吹き出しに変えたので、板と文字は要らない
        /// </summary>
        public void RemoveOldBoards()
        {
            RemoveChild("Board");
            RemoveChild("Backing");
        }

        void RemoveChild(string name)
        {
            Transform t = transform.Find(name);
            if (t == null) return;
            if (Application.isPlaying) Destroy(t.gameObject);
            else DestroyImmediate(t.gameObject);
        }

        // ---------------- 文を作る ----------------

        string Compose()
        {
            _sb.Length = 0;

            // ---- ロボット（台ごとに仕事と、止まっているときは理由）----
            IList<ServingRobotController> rs = ops.Robots;
            _sb.Append("ロボット×").Append(rs.Count).Append('\n');
            for (int i = 0; i < rs.Count; i++)
            {
                ServingRobotController r = rs[i];
                if (r == null) continue;
                _sb.Append("  ").Append(r.robotId).Append("号機 ").Append(RobotJob(r));
                string why = StopText(r);
                if (why.Length > 0) _sb.Append(" ／ ").Append(why);
                _sb.Append('\n');
            }

            // ---- スタッフ（仕事ごとの人数）----
            AppendStaff();

            // ---- ロボット待ち（スタッフと客の人数）----
            int waitStaff, waitGuest, detours;
            CountYielding(out waitStaff, out waitGuest, out detours);
            _sb.Append("ロボット待ち スタッフ ").Append(waitStaff)
               .Append(" 人 ／ 客 ").Append(waitGuest).Append(" 人")
               .Append("（回り道 ").Append(detours).Append(" 人）").Append('\n');

            // ---- 店の様子 ----
            if (groups != null)
                _sb.Append("組 ").Append(groups.GroupsArrived)
                   .Append("（席待ち ").Append(groups.WaitingGroups)
                   .Append("・帰った ").Append(groups.GroupsDone).Append("）");
            _sb.Append(" 皿 ").Append(ops.DishesWaiting).Append('\n');

            // ---- 受け渡し口でロボットを待っている皿（サイトの試算 v2.7）----
            // 料理ができてから robot_first_wait_s のあいだは、ロボットだけが運ぶ。
            // そのあいだ、まだ誰も持っていない皿の数（記録と同じ数え方）
            _sb.Append("受け渡し口でロボットを待っている皿 ")
               .Append(ops.DishesWaitingForRobot).Append('\n');

            _sb.Append("あきらめた ").Append(ops.gaveUpCount)
               .Append(" ／ 移した ").Append(ops.warpedCount)
               .Append(" ／ 卓を空けた ").Append(ops.tableFreedCount).Append('\n');
            _sb.Append("立ち往生 ").Append(ops.rescueCalls).Append(" 回");

            return _sb.ToString();
        }

        /// <summary>ロボットの仕事（LABEL_RULES の言葉）</summary>
        static string RobotJob(ServingRobotController r)
        {
            switch (r.CurrentPhase)
            {
                case ServingRobotController.Phase.ToTable: return "配膳 卓" + r.TableId.ToString("00");
                case ServingRobotController.Phase.Dwell: return "配膳 卓" + r.TableId.ToString("00");
                case ServingRobotController.Phase.ToPass: return "戻る";
                case ServingRobotController.Phase.StuckWait: return "立ち往生";
                default: return "待機";
            }
        }

        /// <summary>
        /// いまの様子（依頼 v9 の 6 章）。走る・止まる・回り込む・よける を出す。
        /// 動いていて何も起きていなければ「走る」
        /// </summary>
        static string StopText(ServingRobotController r)
        {
            if (r.CurrentPhase == ServingRobotController.Phase.StuckWait) return "";   // 仕事の側に出ている
            if (r.CurrentPhase == ServingRobotController.Phase.Dwell) return "";       // 卓での受け渡し中

            bool moving = r.spec != null && r.SpeedMps > r.spec.stoppedSpeedEpsilon;

            // よける・回り込むは、走っていても出す（何をしているかが分かるように）
            if (r.YieldingToRobot) return moving ? "よける：場所へ動いている" : "よける：相手を待っている";
            if (r.Detouring) return moving ? "回り込む" : "回り込む道を探している";

            if (moving) return "走る";

            string why = r.StopCauseNow;
            if (why.Length == 0) return "";
            return "一時停止：" + why;
        }

        void AppendStaff()
        {
            IReadOnlyList<StaffAgent> staff = StaffAgent.All;
            _sb.Append("スタッフ×").Append(staff.Count);

            Dictionary<string, int> byJob = new Dictionary<string, int>();
            for (int i = 0; i < staff.Count; i++)
            {
                if (staff[i] == null) continue;
                string job = MonitorJobName(staff[i]);
                int n;
                byJob.TryGetValue(job, out n);
                byJob[job] = n + 1;
            }

            bool first = true;
            foreach (KeyValuePair<string, int> kv in byJob)
            {
                _sb.Append(first ? "  " : "／").Append(kv.Key).Append(' ').Append(kv.Value);
                first = false;
            }
            _sb.Append('\n');
        }

        /// <summary>
        /// スタッフの仕事の名前。記録（staff_time_s）の言葉は過去の記録と比べるため変えないので、
        /// ここだけ LABEL_RULES の表の言葉に直して出す（料理 → 配膳）。
        /// </summary>
        static string MonitorJobName(StaffAgent a)
        {
            if (a.CurrentJob == null) return "待機";
            switch (a.CurrentJob.kind)
            {
                case StaffJobKind.Guide: return "案内";
                case StaffJobKind.Serve: return "配膳";
                case StaffJobKind.Call: return "呼び出し";
                case StaffJobKind.Pay: return "会計";
                case StaffJobKind.Clear: return "片付け";
                case StaffJobKind.Rescue: return "立ち往生";
            }
            return "待機";
        }

        /// <summary>
        /// いまロボットをよけている人を数える。
        ///   ロボット待ち … 入口で待つ・通路の中で下がる（止まっている）
        ///   回り道       … 別の通路へまわっている（止まっていない）
        /// </summary>
        void CountYielding(out int waitStaff, out int waitGuest, out int detours)
        {
            waitStaff = 0; waitGuest = 0; detours = 0;

            IReadOnlyList<PersonBody> all = PersonBody.All;
            for (int i = 0; i < all.Count; i++)
            {
                PersonBody b = all[i];
                if (b == null || b.kind == PersonKind.Seated) continue;

                PersonRobotYield y = b.GetComponent<PersonRobotYield>();
                if (y == null || !y.Intervening) continue;

                if (y.CurrentMode == PersonRobotYield.Mode.Detour) { detours++; continue; }
                if (b.kind == PersonKind.Staff) waitStaff++;
                else waitGuest++;
            }
        }
    }
}
```

### Assets/ServiceSim/Service/ServiceOps.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>料理1皿</summary>
    public class DishOrder
    {
        public int id;
        public int groupId;
        public int tableIndex;      // StoreBuilder.Tables の番号
        public int tableId;         // 卓の番号（報告用）
        public float readyS = -1f;  // 受け渡し口に出た時刻（店の中, 秒）
        public float arrivedS = -1f;// 卓に届いた時刻

        /// <summary>はじめて誰かが持ち上げた時刻（受け渡し口で待った時間を出すため）</summary>
        public float firstTakenS = -1f;

        /// <summary>最後に持ち上げられた時刻（運んでいた時間を出すため）</summary>
        public float lastTakenS = -1f;
        public bool fragile;        // デザート・ドリンク
        public bool taken;          // 誰かが持っている
        public bool byRobot;        // ロボットが運んだ
        public int requeued;        // 受け渡し口に戻された回数

        /// <summary>
        /// 同じ通路に2台を集めないために、後に回した皿か（v5・A）。
        /// 数を1皿につき1回だけ数えるための印（毎コマ数えないため）
        /// </summary>
        public bool deferredForAisle;

        /// <summary>
        /// スタッフも運べるようになった時刻（店の中, 秒）。まだなら -1（サイトの試算 v2.7）。
        ///
        /// 料理ができてから robot_first_wait_s のあいだは、ロボットだけが運ぶ。
        /// その時間を過ぎたら、ここに時刻が入り、スタッフの仕事板にも出る
        /// （そこからはロボットとスタッフの先に空いた方が運ぶ）。
        /// </summary>
        public float staffEligibleS = -1f;

        /// <summary>
        /// はじめからスタッフも運べる皿か（サイトの試算 v2.7）。
        /// 割れ物を人が運ぶ設定（fragile_by=staff）の倒れやすい料理と、ロボット0台のとき。
        /// この皿は「ロボットを待っていた皿」として数えない
        /// （待つ決まりがはじめから当てはまらないため）
        /// </summary>
        public bool staffFromStart;

        /// <summary>
        /// ロボットだけを待つ時間を過ぎたか（サイトの試算 v2.7）。
        /// staffFromStart の皿では立たない
        /// </summary>
        public bool robotWaitExpired;
    }

    /// <summary>スタッフの仕事。優先は試算の staff_job と同じ（1=案内・配膳・呼び出し・会計、2=片付け）</summary>
    /// <summary>
    /// スタッフの仕事の種類。
    /// Rescue（v5 3章）は、動けなくなったロボットのところへ歩いて行って rescue_s 動かす仕事。
    /// サイトの決まりで「ほかの仕事より先」なので、優先度は 0（ほかは 1 と 2）。
    /// </summary>
    public enum StaffJobKind { Guide, Serve, Call, Pay, Clear, Rescue }

    public class StaffJob
    {
        public StaffJobKind kind;
        public int priority;
        public int groupId;
        public int tableIndex;
        public int tableId;
        public DishOrder dish;      // Serve のときだけ
        public ServingRobotController robot;  // Rescue のときだけ（動けなくなった台）
        public bool taken;
        public float postedS;

        /// <summary>スタッフが取った時刻（店の中, 秒）。まだなら -1。入店待ちの内訳に使う</summary>
        public float takenS = -1f;

        /// <summary>
        /// この仕事を何回あきらめたか（v5 3章）。**仕事ごと**に数える。
        /// 人ごとに数えていたときは、仕事が人から人へ回ると誰も上限（staffGiveUpMaxTries）に
        /// 届かず、「3回で移して済ませる」逃がし弁が働かないまま回り続けた。
        /// </summary>
        public int tries;
        /// <summary>仕事が終わったときに呼ばれる（出した側が入れる）</summary>
        public System.Action onDone;
    }

    /// <summary>
    /// あきらめた・移した・卓を空けた、の1回ぶん（give_ups.csv に1行）。
    /// 「移した」「卓を空けた」が多いときは、人が通れない店になっているしるし。
    /// </summary>
    public struct GiveUpRow
    {
        public float tS;         // 店の中の時刻（秒）
        public string who;       // 客 / スタッフ / 組
        public string job;       // 何をしていたか
        public int tableId;      // 関わる卓（無ければ 0）
        public float toX, toZ;   // 向かっていた先
        public float x, z;       // そのときの位置
        public string aisle;     // そのときいた通路
        public float waitedS;    // 着けないまま待った秒数
        public string action;    // あきらめた / 移した / 卓を空けた
    }

    /// <summary>
    /// 人がロボットに近づきすぎた1回ぶん（contacts.csv に1行）。
    /// ロボットの外周から PeopleSpec.robotContactGapM 以内に入ったとき。
    /// </summary>
    public struct ContactRow
    {
        public float tS;         // 店の中の時刻（秒）
        public float x, z;       // そのときの位置
        public string aisle;     // そのときいた通路
        public string place;     // そのときいた場所（レジ・待合・ドリンクバーなど。無ければ空）
        public string who;       // 誰が（種類:名前）
        public bool careless;    // よけ方を守らない人だったか
        public int robotId;      // 相手の台
        public float gapM;       // ロボットの外周からの空き（m。負なら重なった）
    }

    /// <summary>
    /// 届いた料理1皿ぶん（dishes.csv に1行）。サイトの試算 v2.7 の割り当ての決まりを確かめるため。
    /// 「誰が運んだか」「受け渡し口で待った秒数」「2分を過ぎて人が拾ったか」を残す。
    /// </summary>
    public struct DishRow
    {
        public int dishId;
        public int tableId;
        public int groupId;
        public bool fragile;

        /// <summary>料理ができた時刻（店の中, 秒）</summary>
        public float readyS;

        /// <summary>はじめて誰かが持ち上げた時刻</summary>
        public float firstTakenS;

        /// <summary>卓に届いた時刻</summary>
        public float arrivedS;

        /// <summary>誰が運んだか（ロボット / スタッフ）</summary>
        public bool byRobot;

        /// <summary>受け渡し口で待った秒数（できてから、はじめて持ち上げられるまで）</summary>
        public float passWaitS;

        /// <summary>運んでいた秒数（最後に持ち上げられてから、卓に届くまで）</summary>
        public float carryS;

        /// <summary>途中で戻されて失った秒数</summary>
        public float requeueS;

        /// <summary>受け渡し口に戻された回数</summary>
        public int requeued;

        /// <summary>はじめからスタッフも運べる皿だったか（fragile_by=staff・ロボット0台）</summary>
        public bool staffFromStart;

        /// <summary>ロボットだけを待つ時間（robot_first_wait_s）を過ぎたか</summary>
        public bool robotWaitExpired;

        /// <summary>
        /// 2分（robot_first_wait_s）を過ぎて人が拾った皿か。
        /// robotWaitExpired かつスタッフが運んだもの（staffFromStart の皿は数えない）
        /// </summary>
        public bool lateStaffPickup;

        /// <summary>同じ通路に2台を集めない決まりで後に回された皿か（v5・A）</summary>
        public bool deferredForAisle;
    }

    /// <summary>
    /// 誰が誰に譲った1回ぶん（yields.csv に1行。2026-10-23）。
    /// 譲る順番は RobotSpec（ロボット＞スタッフ＞客）。
    /// 譲り終わったとき（相手が通り過ぎた・待ちすぎてやめた）に1行足す。
    /// </summary>
    public struct YieldRow
    {
        /// <summary>店の中の時刻（秒）。譲り**始めた**時刻</summary>
        public float tS;

        /// <summary>譲った側（ロボット1号機 / スタッフ:名前 / 客:名前）</summary>
        public string who;

        /// <summary>譲った側の順番（0=ロボット・1=スタッフ・2=客）</summary>
        public int rank;

        /// <summary>相手（ロボット2号機 など）</summary>
        public string toWhom;

        /// <summary>譲り始めた位置</summary>
        public float x, z;

        /// <summary>そのときいた通路（無ければ空）</summary>
        public string aisle;

        /// <summary>そのときいた場所（レジ・待合など。無ければ空）</summary>
        public string place;

        /// <summary>受け渡し口と客席をつなぐ角の中だったか</summary>
        public bool inCorner;

        /// <summary>譲って待った秒数</summary>
        public float waitedS;

        /// <summary>相手の道から横へどけたか（false なら来た道へ戻って待った）</summary>
        public bool steppedAside;

        /// <summary>相手が通り過ぎて終わったか（false なら待ちすぎてやめた）</summary>
        public bool passed;
    }

    /// <summary>届けられずに受け渡し口へ戻した1回ぶん（requeues.csv に1行）</summary>
    public struct RequeueRow
    {
        public float tS;
        public int dishId;
        public int tableId;
        public int robotId;
        public bool fragile;
        public float readyS;      // 料理ができた時刻
        public int requeuedSoFar; // この皿が戻された回数（この回を含む）
        public string why;        // 動けなくなった / 打ち切り / 取りやめ / 着かなかった
    }

    /// <summary>卓1つの状態</summary>
    public class TableSlot
    {
        public int index;
        public int tableId;
        public Vector3 deliveryPoint;
        public Vector3 aisleEntrance;
        public Bounds aisleZone;

        /// <summary>この卓へ行くときに通る細い通路の番号（店の形の aisles）。-1 なら通らない</summary>
        public int aisleIndex = -1;
        public Vector3 seatPoint;

        /// <summary>何人かけの卓か（2人席なら2、4人席なら4）</summary>
        public int seatCount = 4;

        public int groupId = -1;    // -1 なら空き
        public bool clearing;       // 片付け中（まだ使えない）
        public bool Free { get { return groupId < 0 && !clearing; } }
    }

    /// <summary>
    /// 店の回し方の中心。卓の空き、受け渡し口の料理の待ち行列、スタッフの仕事板を持ち、
    /// 空いたロボットに料理をまとめて渡す（試算の deliver / _robot_run と同じ決まり）。
    ///
    /// この script は店の運営だけを扱う。人の歩き方はそれぞれの script、
    /// ロボットの止まる決まりは ServingRobotController に任せる。
    /// 数字は ServiceSpec の .asset に置く。
    /// </summary>
    public class ServiceOps : MonoBehaviour
    {
        [Header("設定")]
        public ServiceSpec spec;
        public RobotSpec robotSpec;

        [Tooltip("卓と通路を読む先")]
        public StoreBuilder store;

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _freeTables;
        [SerializeField] int _dishesWaiting;

        /// <summary>受け渡し口でロボットを待っている皿（サイトの試算 v2.7）</summary>
        [SerializeField] int _dishesWaitingForRobot;
        [SerializeField] int _jobsWaiting;

        readonly List<TableSlot> _tables = new List<TableSlot>();
        readonly List<DishOrder> _pass = new List<DishOrder>();
        readonly List<StaffJob> _jobs = new List<StaffJob>();
        readonly List<ServingRobotController> _robots = new List<ServingRobotController>();
        readonly Dictionary<ServingRobotController, List<DishOrder>> _carrying =
            new Dictionary<ServingRobotController, List<DishOrder>>();
        readonly Dictionary<ServingRobotController, List<DishOrder>> _lastBatch =
            new Dictionary<ServingRobotController, List<DishOrder>>();

        /// <summary>
        /// 止まる所の番号 → その所で降ろす皿（依頼 v8 の 1）。
        ///
        /// StartRun に渡す stops を作るループは SlotOf が null の皿を飛ばすので、
        /// 「止まる所の番号＝batch の番号」とは限らない。だからこの対応表で引く。
        /// </summary>
        readonly Dictionary<ServingRobotController, List<DishOrder>> _stopDishes =
            new Dictionary<ServingRobotController, List<DishOrder>>();

        int _nextDishId = 1;

        // ---- 記録（TripLogger と ExperimentRunner が読む）----
        public readonly List<float> dishWaitMin = new List<float>();   // 料理ができてから届くまで（分）
        public readonly List<float> seatWaitMin = new List<float>();   // 入店から案内が始まるまで（分）
        public readonly List<float> turnMin = new List<float>();       // 退店から卓が空くまで（分）

        // ---- 入店待ちの内訳（v5 3章・サイトの試算（haizen_sim.py）と同じ分け方）----
        //   seat_wait   … 店に来てから案内が始まるまで（seatWaitMin）
        //   blocked_wait… そのうち、卓は空いたのに片付けが終わらず待った分
        //   full_wait   … seat_wait − blocked_wait（ほんとうに満席で待った分）
        //   guide_wait  … 案内の仕事が板に出てから、スタッフが取るまで
        //   staff_wait  … blocked_wait ＋ guide_wait（店側が追いつかなかった分）
        //   entry_wait  … 店に来てから卓に着くまで（案内の歩きと guide_s を含む）
        /// <summary>entry_wait_mean：店に来てから案内されて卓に着くまで（分）</summary>
        public readonly List<float> entryWaitMin = new List<float>();

        /// <summary>full_wait_mean：席待ち − blocked_wait（分）</summary>
        public readonly List<float> fullWaitMin = new List<float>();

        /// <summary>blocked_wait_mean：卓は空いたが片付けが終わらず待った分（分）</summary>
        public readonly List<float> blockedWaitMin = new List<float>();

        /// <summary>guide_wait_mean：案内の仕事が板に出てから取られるまで（分）</summary>
        public readonly List<float> guideWaitMin = new List<float>();

        /// <summary>staff_wait_mean：blocked_wait ＋ guide_wait（分）</summary>
        public readonly List<float> staffWaitMin = new List<float>();
        public int robotDishes { get; private set; }
        public int staffDishes { get; private set; }
        public int requeuedDishes { get; private set; }

        // ---- あきらめた記録（B1）----
        readonly List<GiveUpRow> _giveUps = new List<GiveUpRow>();
        public IReadOnlyList<GiveUpRow> GiveUps { get { return _giveUps; } }

        // ---- 人がロボットをよけた記録 ----
        readonly List<ContactRow> _contacts = new List<ContactRow>();

        /// <summary>人がロボットに近づきすぎた1回ずつ（contacts.csv）</summary>
        public IReadOnlyList<ContactRow> Contacts { get { return _contacts; } }

        // ---- 譲り合いの記録（yields.csv・2026-10-23）----
        readonly List<YieldRow> _yields = new List<YieldRow>();

        /// <summary>誰が誰に譲った1回ずつ（yields.csv）</summary>
        public IReadOnlyList<YieldRow> Yields { get { return _yields; } }

        /// <summary>
        /// 譲り1回ぶんを残す。譲り**終わった**ときに呼ぶ（待った秒数が決まってから）。
        /// startS は譲り始めた時刻、at は譲り始めた位置。
        /// </summary>
        public void AddYield(float startS, Vector3 at, string who, int rank, string toWhom,
                             float waitedS, bool steppedAside, bool passed)
        {
            YieldRow r = new YieldRow();
            r.tS = startS;
            r.who = who;
            r.rank = rank;
            r.toWhom = toWhom;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.place = store != null ? store.PlaceNameAt(at) : "";
            // 依頼 v9：角の箱は SiteRoutes のときだけ働く
            r.inCorner = robotSpec != null && !robotSpec.UseMapPlanner && robotSpec.InCorner(at);
            r.waitedS = Mathf.Max(0f, waitedS);
            r.steppedAside = steppedAside;
            r.passed = passed;
            _yields.Add(r);
        }

        /// <summary>人がロボットを待った回数（入口で待つ＋通路の中で下がる）</summary>
        public int robotYieldWaits { get; private set; }

        /// <summary>人がロボットを待っていた秒数の合計</summary>
        public float robotYieldWaitSeconds { get; private set; }

        /// <summary>人が回り道した回数</summary>
        public int robotYieldDetours { get; private set; }

        // ---- レジの前の混み具合（調べるための記録）----
        /// <summary>レジの前にいた人数のいちばん多いとき</summary>
        public int registerCrowdMax { get; private set; }

        float _registerCrowdSum;
        int _registerCrowdSamples;

        // ---- 会計のときスタッフが店員側（レジの内側）にいたか ----
        /// <summary>会計をした回数</summary>
        public int registerPayCount { get; private set; }

        /// <summary>そのうち、店員側にいなかった回数（0 になるはず）</summary>
        public int registerPayNotAtBackCount { get; private set; }

        /// <summary>立つ点から 0.1 m より離れて会計した回数（0 になるはず）</summary>
        public int registerPayOffSpotCount { get; private set; }

        /// <summary>立つ点と実際に立った位置の距離のいちばん大きいもの（m）</summary>
        public float registerPayMaxOffsetM { get; private set; }

        /// <summary>立つ点からの距離の合計（平均を出すため, m）</summary>
        float _registerPayOffsetSum;

        /// <summary>
        /// いま店員側（レジの内側）で会計しているスタッフがいるか。
        /// 店員側は 0.8 × 1.2 m で1人ぶんなので、会計の仕事は同時に1つだけ取れる。
        /// </summary>
        public bool RegisterBackBusy { get; private set; }

        /// <summary>店員側に、会計でないスタッフがいた秒数（0 になるはず）</summary>
        public float registerBackIntruderSeconds { get; private set; }

        /// <summary>店員側に、会計でないスタッフがいた回数（0 になるはず）</summary>
        public int registerBackIntruderCount { get; private set; }

        /// <summary>会計でないスタッフが店員側にいたことを残す（StaffAgent が毎コマ呼ぶ）</summary>
        public void AddRegisterBackIntruder(float dt, bool firstFrame)
        {
            registerBackIntruderSeconds += dt;
            if (firstFrame) registerBackIntruderCount++;
        }

        /// <summary>立つ点と実際に立った位置の距離の平均（m）</summary>
        public float RegisterPayMeanOffsetM
        {
            get { return registerPayCount > 0 ? _registerPayOffsetSum / registerPayCount : 0f; }
        }

        /// <summary>
        /// 会計1回ぶんを数える。
        /// atBack が false なら店員側にいなかった。offsetM は立つ点からの距離。
        /// どちらも 0 になるはず（ならなければ、スタッフが店員側に入れていない）。
        /// </summary>
        public void AddRegisterPay(bool atBack, float offsetM)
        {
            registerPayCount++;
            if (!atBack) registerPayNotAtBackCount++;

            _registerPayOffsetSum += offsetM;
            if (offsetM > registerPayMaxOffsetM) registerPayMaxOffsetM = offsetM;
            float limit = spec != null ? spec.registerPayOffSpotM : 0.1f;
            if (offsetM > limit) registerPayOffSpotCount++;
        }

        /// <summary>レジの前にいた人数の平均</summary>
        public float RegisterCrowdMean
        {
            get { return _registerCrowdSamples > 0 ? _registerCrowdSum / _registerCrowdSamples : 0f; }
        }

        // ---- スタッフの時間の使い方（仕事ごとの秒数）----
        readonly Dictionary<string, float> _staffTime = new Dictionary<string, float>();

        /// <summary>スタッフが何にどれだけ時間を使ったか（仕事の名前 → 秒）</summary>
        public IDictionary<string, float> StaffTime { get { return _staffTime; } }

        // ---- 料理が届くまでの内訳（運んだのがロボットか人かで分ける）----
        /// <summary>届いた料理1皿ぶんの内訳（秒）</summary>
        public struct DishParts
        {
            public bool byRobot;
            public float passWaitS;   // 受け渡し口で待った（できてから、はじめて持ち上げられるまで）
            public float carryS;      // 運んでいた（最後に持ち上げられてから、卓に届くまで）
            public float requeueS;    // 途中で戻されて失った（残り）
            public int requeued;      // 戻された回数
        }

        readonly List<DishParts> _dishParts = new List<DishParts>();

        /// <summary>届いた料理の、届くまでの内訳（summary.json に出す）</summary>
        public IReadOnlyList<DishParts> DishPartsList { get { return _dishParts; } }

        // ---- 皿ごとの記録（dishes.csv・サイトの試算 v2.7）----
        readonly List<DishRow> _dishRows = new List<DishRow>();

        /// <summary>届いた料理1皿ずつの記録（dishes.csv）</summary>
        public IReadOnlyList<DishRow> DishRows { get { return _dishRows; } }

        /// <summary>
        /// 2分（robot_first_wait_s）を過ぎて人が拾った皿の数（summary.json に出す）。
        /// ロボットだけを待つ時間を過ぎてから、スタッフが運んだ皿
        /// </summary>
        public int lateStaffDishes { get; private set; }

        /// <summary>
        /// いま受け渡し口でロボットを待っている皿の数（キッチンのモニターに出す）。
        /// ロボットだけを待つ時間（robot_first_wait_s）の中にいて、まだ誰も持っていない皿。
        /// はじめからスタッフも運べる皿（fragile_by=staff・ロボット0台）は数えない
        /// </summary>
        public int DishesWaitingForRobot { get { return _dishesWaitingForRobot; } }

        // ---- 仕事が板で待った秒数（仕事ごと）----
        readonly Dictionary<string, float> _jobWaitSum = new Dictionary<string, float>();
        readonly Dictionary<string, int> _jobWaitCount = new Dictionary<string, int>();

        /// <summary>仕事が板に出てから取られるまでの秒数の合計（仕事の名前 → 秒）</summary>
        public IDictionary<string, float> JobWaitSum { get { return _jobWaitSum; } }

        /// <summary>仕事が取られた回数（仕事の名前 → 回）</summary>
        public IDictionary<string, int> JobWaitCount { get { return _jobWaitCount; } }

        void RecordJobWait(StaffJob j)
        {
            if (j == null) return;
            string k = StaffAgent.JobKindName(j.kind);
            float v; int n;
            _jobWaitSum.TryGetValue(k, out v);
            _jobWaitCount.TryGetValue(k, out n);
            _jobWaitSum[k] = v + Mathf.Max(0f, NowS - j.postedS);
            _jobWaitCount[k] = n + 1;
        }

        /// <summary>スタッフの時間を足す（StaffAgent が毎コマ呼ぶ）</summary>
        public void AddStaffTime(string what, float dt)
        {
            if (string.IsNullOrEmpty(what)) return;
            float v;
            _staffTime.TryGetValue(what, out v);
            _staffTime[what] = v + dt;
        }

        // ---- レジの前に並ぶ点（会計に立つ人が1人ずつ）----
        readonly List<Vector3> _regQueues = new List<Vector3>();
        readonly List<bool> _regQueueUsed = new List<bool>();

        /// <summary>レジの並ぶ点を1つ取る。無ければ -1（会計に立つ人も席で待つ）</summary>
        public int TakeRegisterQueueSpot(out Vector3 spot)
        {
            for (int i = 0; i < _regQueueUsed.Count; i++)
            {
                if (_regQueueUsed[i]) continue;
                _regQueueUsed[i] = true;
                spot = _regQueues[i];
                return i;
            }
            spot = RegisterPointOrPass();
            return -1;
        }

        /// <summary>取ったレジの並ぶ点を空ける</summary>
        public void ReleaseRegisterQueueSpot(int index)
        {
            if (index < 0 || index >= _regQueueUsed.Count) return;
            _regQueueUsed[index] = false;
        }

        Vector3 RegisterPointOrPass()
        {
            return store != null ? store.RegisterPoint : Vector3.zero;
        }
        public int gaveUpCount { get; private set; }      // あきらめた
        public int warpedCount { get; private set; }      // 移した
        public int tableFreedCount { get; private set; }  // 卓を空けた

        // ---- 立ち往生でスタッフを呼んだ回数（v5 3章・meta.metrics の rescues）----
        /// <summary>
        /// 同じ通路に2台を集めないために、料理を後に回した回数（v5・A）。
        /// 多いときは、2台が同じ通路の卓ばかりを受け持とうとしているしるし
        /// </summary>
        public int aisleDeferrals { get; private set; }

        /// <summary>動けなくなって、スタッフを呼んだ回数</summary>
        public int rescueCalls { get; private set; }

        /// <summary>そのうち、スタッフが着いて動かした回数</summary>
        public int rescueDone { get; private set; }

        /// <summary>スタッフが着くまでロボットが待った秒数の合計</summary>
        public float rescueWaitSeconds { get; private set; }

        // ---- ロボットのふさがっている割合（robot_util）----
        float _robotBusyS, _robotAliveS;

        /// <summary>robot_util：ロボットが空いていなかった時間の割合</summary>
        public float RobotUtil { get { return _robotAliveS > 0.01f ? _robotBusyS / _robotAliveS : 0f; } }

        // ---- スタッフが歩いた距離（walk_km_per_staff）----
        float _staffWalkM;
        int _staffCount;

        /// <summary>スタッフ1人が歩いた距離（km）</summary>
        public float WalkKmPerStaff
        {
            get { return _staffCount > 0 ? _staffWalkM / 1000f / _staffCount : 0f; }
        }

        /// <summary>スタッフの人数を伝える（walk_km_per_staff を出すため）</summary>
        public void SetStaffCount(int n) { _staffCount = n; }

        /// <summary>スタッフが1コマで歩いた距離を足す（StaffAgent が呼ぶ）</summary>
        public void AddStaffWalk(float m)
        {
            if (m > 0f && m < 1f) _staffWalkM += m;   // 1 m を超える跳びは「移した」ぶんなので数えない
        }

        // ---- 戻した皿の記録（B8）----
        readonly List<RequeueRow> _requeues = new List<RequeueRow>();
        public IReadOnlyList<RequeueRow> Requeues { get { return _requeues; } }

        // ---- 椅子を引いた回数（B6）----
        readonly Dictionary<string, int> _chairPullsByReason = new Dictionary<string, int>();
        readonly Dictionary<int, int> _chairPullsByTable = new Dictionary<int, int>();
        public int chairPulls { get; private set; }
        public IDictionary<string, int> ChairPullsByReason { get { return _chairPullsByReason; } }
        public IDictionary<int, int> ChairPullsByTable { get { return _chairPullsByTable; } }

        // ---- ドリンクバーの場所取り（B4）----
        readonly List<Vector3> _dbStands = new List<Vector3>();
        readonly List<Vector3> _dbQueues = new List<Vector3>();
        readonly List<bool> _dbStandUsed = new List<bool>();
        readonly List<bool> _dbQueueUsed = new List<bool>();

        /// <summary>並ぶ点に回された回数</summary>
        public int drinkBarQueued { get; private set; }

        /// <summary>立つ点も並ぶ点も空いていなくて、あきらめた回数</summary>
        public int drinkBarGaveUp { get; private set; }

        /// <summary>並んで待った秒数（分で記録）</summary>
        public readonly List<float> drinkBarWaitMin = new List<float>();

        public IList<ServingRobotController> Robots { get { return _robots; } }
        public IList<TableSlot> Tables { get { return _tables; } }
        public int FreeTableCount { get { return _freeTables; } }
        public int DishesWaiting { get { return _dishesWaiting; } }

        /// <summary>店の中の時刻（秒）。ExperimentRunner が進める</summary>
        public float NowS { get; private set; }

        // ---------------- 用意 ----------------

        public void Setup()
        {
            _tables.Clear();
            _pass.Clear();
            _jobs.Clear();
            _carrying.Clear();
            _stopDishes.Clear();        // 止まる所と皿の対応表（依頼 v8 の 1）
            _lastBatch.Clear();
            dishWaitMin.Clear();
            seatWaitMin.Clear();
            turnMin.Clear();
            entryWaitMin.Clear();
            fullWaitMin.Clear();
            blockedWaitMin.Clear();
            guideWaitMin.Clear();
            staffWaitMin.Clear();
            aisleDeferrals = 0;
            rescueCalls = 0;
            rescueDone = 0;
            rescueWaitSeconds = 0f;
            _robotBusyS = 0f;
            _robotAliveS = 0f;
            _staffWalkM = 0f;
            robotDishes = 0;
            staffDishes = 0;
            requeuedDishes = 0;
            _giveUps.Clear();
            _requeues.Clear();
            _contacts.Clear();
            _yields.Clear();
            _staffTime.Clear();
            _dishParts.Clear();
            _dishRows.Clear();
            lateStaffDishes = 0;
            _dishesWaitingForRobot = 0;
            _jobWaitSum.Clear();
            _jobWaitCount.Clear();
            registerCrowdMax = 0;
            registerPayCount = 0;
            registerPayNotAtBackCount = 0;
            registerPayOffSpotCount = 0;
            registerPayMaxOffsetM = 0f;
            _registerPayOffsetSum = 0f;
            RegisterBackBusy = false;
            registerBackIntruderSeconds = 0f;
            registerBackIntruderCount = 0;
            _registerCrowdSum = 0f;
            _registerCrowdSamples = 0;
            _regQueues.Clear();
            _regQueueUsed.Clear();
            robotYieldWaits = 0;
            robotYieldWaitSeconds = 0f;
            robotYieldDetours = 0;
            robotStuckDetours = 0;
            _chairPullsByReason.Clear();
            _chairPullsByTable.Clear();
            chairPulls = 0;
            gaveUpCount = 0;
            warpedCount = 0;
            tableFreedCount = 0;
            _nextDishId = 1;
            NowS = 0f;

            drinkBarQueued = 0;
            drinkBarGaveUp = 0;
            drinkBarWaitMin.Clear();
            _dbStands.Clear();
            _dbQueues.Clear();
            _dbStandUsed.Clear();
            _dbQueueUsed.Clear();

            if (store == null) return;
            IList<StoreBuilder.TableInfo> t = store.Tables;
            for (int i = 0; i < t.Count; i++)
            {
                TableSlot s = new TableSlot();
                s.index = i;
                s.tableId = t[i].tableId;
                s.deliveryPoint = t[i].deliveryPoint;
                s.aisleEntrance = t[i].aisleEntrance;
                s.aisleZone = t[i].aisleZone;
                s.aisleIndex = t[i].aisleIndex;
                s.seatPoint = t[i].center;
                s.seatCount = t[i].seatCount;
                _tables.Add(s);
            }
            // ドリンクバーの立つ点・並ぶ点を読む（B4）
            SetupDrinkBar(store.PointsOf(PlaceKind.DrinkBarStand),
                          store.PointsOf(PlaceKind.DrinkBarQueue));

            // レジの前に並ぶ点を読む
            IList<Vector3> rq = store.RegisterQueuePoints;
            for (int i = 0; i < rq.Count; i++) { _regQueues.Add(rq[i]); _regQueueUsed.Add(false); }

            Recount();
        }

        // ---------------- ドリンクバーの場所取り（B4）----------------

        void SetupDrinkBar(IList<Vector3> stands, IList<Vector3> queues)
        {
            for (int i = 0; i < stands.Count; i++) { _dbStands.Add(stands[i]); _dbStandUsed.Add(false); }
            for (int i = 0; i < queues.Count; i++) { _dbQueues.Add(queues[i]); _dbQueueUsed.Add(false); }
        }

        /// <summary>
        /// ドリンクバーの場所を1つ取る（B4）。
        ///   ・空いている立つ点があれば、そこへ
        ///   ・埋まっていれば、端の並ぶ点へ
        ///   ・それも埋まっていれば取れない（客はあきらめて席に戻る）
        /// spot は向かう点、queued は並ぶ点だったか。
        /// 返した番号を ReleaseDrinkBarSpot に渡して空ける。
        /// </summary>
        public int TakeDrinkBarSpot(out Vector3 spot, out bool queued)
        {
            for (int i = 0; i < _dbStandUsed.Count; i++)
            {
                if (_dbStandUsed[i]) continue;
                _dbStandUsed[i] = true;
                spot = _dbStands[i];
                queued = false;
                return i;
            }

            for (int i = 0; i < _dbQueueUsed.Count; i++)
            {
                if (_dbQueueUsed[i]) continue;
                _dbQueueUsed[i] = true;
                spot = _dbQueues[i];
                queued = true;
                drinkBarQueued++;
                return 1000 + i;      // 1000 以上は並ぶ点
            }

            spot = _dbStands.Count > 0 ? _dbStands[0] : Vector3.zero;
            queued = false;
            drinkBarGaveUp++;
            return -1;
        }

        /// <summary>取った場所を空ける。waitedS は並んで待った秒数</summary>
        public void ReleaseDrinkBarSpot(int token, float waitedS)
        {
            if (token < 0) return;
            if (token >= 1000)
            {
                int i = token - 1000;
                if (i < _dbQueueUsed.Count) _dbQueueUsed[i] = false;
                drinkBarWaitMin.Add(Mathf.Max(0f, waitedS / 60f));
                return;
            }
            if (token < _dbStandUsed.Count) _dbStandUsed[token] = false;
        }

        /// <summary>ドリンクバーに立つ点があるか（無ければ今までどおり1点へ行く）</summary>
        public bool HasDrinkBarSpots { get { return _dbStands.Count > 0; } }

        public void RegisterRobot(ServingRobotController r)
        {
            if (r == null || _robots.Contains(r)) return;
            _robots.Add(r);
            _carrying[r] = new List<DishOrder>();
            r.ops = this;               // 立ち往生したときスタッフを呼ぶ先（v5 3章）
            // 待機場所を先に教える（1往復めの戻りから、戻りの線を使えるように。v5）
            if (store != null)
            {
                IList<Vector3> spots = store.RobotStandbyPoints;
                if (spots.Count > 0) r.SetStandbyPoint(spots[(_robots.Count - 1) % spots.Count]);
            }
            r.TripCompleted += OnRobotRunDone;
            r.StopArrived += OnRobotStopArrived;     // 卓に着いた時点で届いたにする（依頼 v8 の 1）
        }

        public void ClearRobots()
        {
            for (int i = 0; i < _robots.Count; i++)
            {
                if (_robots[i] == null) continue;
                _robots[i].TripCompleted -= OnRobotRunDone;
                _robots[i].StopArrived -= OnRobotStopArrived;
            }
            _robots.Clear();
            _carrying.Clear();
            _stopDishes.Clear();
            _lastBatch.Clear();
        }

        /// <summary>店の中の時刻を進める（ExperimentRunner が毎コマ呼ぶ）</summary>
        public void Advance(float dt)
        {
            NowS += dt;
            AssignRobots();
            ReleaseDishesToStaff();
            ParkIdleRobots();
            SampleRegisterCrowd();
            SampleRobotUtil(dt);
            Recount();
        }

        /// <summary>
        /// ロボットがふさがっていた時間を数える（robot_util）。
        /// 待機場所へ下がっているあいだ・受け渡し口で空のまま待っているあいだは Idle / Done なので
        /// 「空いている」。動けなくなって人を待っているあいだ（StuckWait）は「ふさがっている」。
        /// </summary>
        void SampleRobotUtil(float dt)
        {
            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                _robotAliveS += dt;
                ServingRobotController.Phase p = r.CurrentPhase;
                if (p != ServingRobotController.Phase.Idle &&
                    p != ServingRobotController.Phase.Done) _robotBusyS += dt;
            }
        }

        /// <summary>
        /// レジの前にいた人数を数える（調べるための記録）。
        /// 待合に立つ客も、通路を歩く人も、スタッフも数える。
        /// </summary>
        void SampleRegisterCrowd()
        {
            if (store == null || spec == null) return;
            if (!store.HasPoint(PlaceKind.Register)) return;

            Vector3 reg = store.RegisterPoint;
            float r2 = spec.registerCrowdRadiusM * spec.registerCrowdRadiusM;

            int n = 0;
            IReadOnlyList<PersonBody> all = PersonBody.All;
            for (int i = 0; i < all.Count; i++)
            {
                PersonBody b = all[i];
                if (b == null || b.kind == PersonKind.Seated) continue;
                Vector3 d = b.transform.position - reg;
                d.y = 0f;
                if (d.sqrMagnitude <= r2) n++;
            }

            if (n > registerCrowdMax) registerCrowdMax = n;
            _registerCrowdSum += n;
            _registerCrowdSamples++;
        }

        /// <summary>
        /// 仕事のないロボットを待機場所へ下げる（B2）。
        /// 受け渡し口の前の点にはスタッフも料理を取りに来るので、空の台を置かない。
        /// 料理を載せるときは AssignRobots が受け渡し口から走り出させる。
        ///
        /// v5：料理が待っていても下げる。
        /// 前は「料理が待っているなら受け渡し口で待つ」にしていたが、そのせいで
        /// 出発する台と戻ってきた台が受け渡し口の前の1点に重なり、ふさぎ合っていた
        /// （20261023 0913 の回で、受け渡し口の前の止まりが「もう1台のロボット」23 回）。
        /// 待機場所は1台に1つなので、重ならない。
        /// 料理の割り当て（AssignRobots）はこの前に走るので、載せる台はそのまま走り出す。
        /// </summary>
        void ParkIdleRobots()
        {
            if (store == null) return;
            IList<Vector3> spots = store.RobotStandbyPoints;
            if (spots.Count == 0) return;

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                if (r.CurrentPhase != ServingRobotController.Phase.Idle &&
                    r.CurrentPhase != ServingRobotController.Phase.Done) continue;

                Vector3 spot = spots[i % spots.Count];
                r.SetStandbyPoint(spot);   // 戻りの線の終わりに使う（v5）
                r.GoStandby(spot);
            }
        }

        void Recount()
        {
            int free = 0;
            for (int i = 0; i < _tables.Count; i++) if (_tables[i].Free) free++;
            _freeTables = free;

            int d = 0;
            for (int i = 0; i < _pass.Count; i++) if (!_pass[i].taken) d++;
            _dishesWaiting = d;

            int j = 0;
            for (int i = 0; i < _jobs.Count; i++) if (!_jobs[i].taken) j++;
            _jobsWaiting = j;
        }

        // ---------------- 卓 ----------------

        /// <summary>
        /// ほかの台が向かっている、または中にいる細い通路の番号（v5・A）。
        ///   ・まだ届けていない卓の通路（往復の途中のもの）
        ///   ・いま体がその通路の区画の中にある台の通路（戻り道で通っているときも押さえる）
        /// </summary>
        HashSet<int> AislesTakenByOthers(ServingRobotController self)
        {
            HashSet<int> busy = new HashSet<int>();

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController o = _robots[i];
                if (o == null || o == self) continue;
                if (o.CurrentPhase == ServingRobotController.Phase.Idle ||
                    o.CurrentPhase == ServingRobotController.Phase.Done) continue;

                IReadOnlyList<int> ids = o.TableIds;
                for (int k = o.StopsCompleted; k < ids.Count; k++)
                {
                    TableSlot s = SlotOfTableId(ids[k]);
                    if (s != null && s.aisleIndex >= 0) busy.Add(s.aisleIndex);
                }

                if (store == null) continue;
                IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
                Vector3 p = o.transform.position;
                for (int a = 0; a < aisles.Count; a++)
                {
                    Bounds b = aisles[a].zone;
                    if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                    busy.Add(a);
                }
            }
            return busy;
        }

        TableSlot SlotOfTableId(int tableId)
        {
            for (int i = 0; i < _tables.Count; i++) if (_tables[i].tableId == tableId) return _tables[i];
            return null;
        }

        /// <summary>
        /// その人数が座れる空き卓を1つ取る。無ければ null。
        /// 「入るうちでいちばん小さい卓」を選ぶので、
        ///   ・1〜2人の組は空いている2人席を先に使い、無ければ4人席
        ///   ・3〜4人の組は4人席だけ
        /// になる（2人席に3人以上は座らせない）。
        /// </summary>
        public TableSlot TakeFreeTable(int groupId, int size)
        {
            TableSlot slot = FindFreeTable(size);
            if (slot == null) return null;

            slot.groupId = groupId;
            Recount();
            return slot;
        }

        /// <summary>その人数が入るうちで、いちばん席数の少ない空き卓。無ければ null</summary>
        TableSlot FindFreeTable(int size)
        {
            int want = Mathf.Max(1, size);
            TableSlot best = null;
            int bestSeats = int.MaxValue;

            for (int i = 0; i < _tables.Count; i++)
            {
                if (!_tables[i].Free) continue;
                int seats = Mathf.Max(1, _tables[i].seatCount);
                if (seats < want) continue;
                if (seats >= bestSeats) continue;
                best = _tables[i];
                bestSeats = seats;
                if (bestSeats == want) break;      // ちょうどの大きさが見つかった
            }
            return best;
        }

        /// <summary>その人数が座れる空き卓があるか</summary>
        public bool HasFreeTableFor(int size)
        {
            return FindFreeTable(size) != null;
        }

        /// <summary>慣らしのために、卓を使っている状態で始める</summary>
        public TableSlot TakeTableAt(int index, int groupId)
        {
            if (index < 0 || index >= _tables.Count) return null;
            if (!_tables[index].Free) return null;
            _tables[index].groupId = groupId;
            Recount();
            return _tables[index];
        }

        /// <summary>片付けが終わって卓が空く</summary>
        public void ReleaseTable(TableSlot slot, float leftS)
        {
            if (slot == null) return;
            slot.groupId = -1;
            slot.clearing = false;
            if (leftS >= 0f) turnMin.Add((NowS - leftS) / 60f);
            Recount();
        }

        public void RecordSeatWait(float arriveS)
        {
            seatWaitMin.Add(Mathf.Max(0f, (NowS - arriveS) / 60f));
        }

        /// <summary>
        /// 入店待ちの内訳を1組ぶん残す（v5 3章）。卓に着いた時に GroupManager が呼ぶ。
        /// 分け方はサイトの試算（haizen_sim.py）と同じ。
        ///   entryWaitS   … 店に来てから卓に着くまで（案内に歩いた時間と guide_s を含む）
        ///   seatWaitS    … 店に来てから案内が始まるまで
        ///   blockedWaitS … そのうち、卓は空いたのに片付けが終わらず待った分
        ///   guideWaitS   … 案内の仕事が板に出てから、スタッフが取るまで
        /// full_wait は seat_wait − blocked_wait、staff_wait は blocked_wait ＋ guide_wait。
        /// </summary>
        public void RecordEntryWait(float entryWaitS, float seatWaitS,
                                    float blockedWaitS, float guideWaitS)
        {
            float blocked = Mathf.Clamp(blockedWaitS, 0f, Mathf.Max(0f, seatWaitS));
            float full = Mathf.Max(0f, seatWaitS - blocked);
            float guide = Mathf.Max(0f, guideWaitS);

            entryWaitMin.Add(Mathf.Max(0f, entryWaitS / 60f));
            fullWaitMin.Add(full / 60f);
            blockedWaitMin.Add(blocked / 60f);
            guideWaitMin.Add(guide / 60f);
            staffWaitMin.Add((blocked + guide) / 60f);
        }

        /// <summary>
        /// 片付け待ちの卓の数（v5 3章・サイトの _acc と同じ blocked_wait を数えるのに使う）。
        /// 客が帰ってから、拭き終わって使えるようになるまでの卓
        /// </summary>
        public int ClearingTableCount
        {
            get
            {
                int n = 0;
                for (int i = 0; i < _tables.Count; i++) if (_tables[i].clearing) n++;
                return n;
            }
        }

        // ---------------- 立ち往生でスタッフを呼ぶ（v5 3章）----------------

        /// <summary>
        /// 動けなくなったロボットのところへスタッフを呼ぶ。
        /// サイトの決まりで「ほかの仕事より先」なので、優先度 0 で板の先頭に出す。
        /// 受け渡し口へ移す（warp）のは、もうしない。
        /// </summary>
        public StaffJob PostRescue(ServingRobotController robot)
        {
            if (robot == null) return null;
            StaffJob j = NewJob(StaffJobKind.Rescue, 0, null, null, 0);
            j.robot = robot;
            _jobs.Insert(0, j);
            rescueCalls++;
            Recount();
            return j;
        }

        /// <summary>スタッフが着いて rescue_s 動かし終わった（ロボットが呼ぶ）</summary>
        public void RescueFinished(float waitedS)
        {
            rescueDone++;
            rescueWaitSeconds += Mathf.Max(0f, waitedS);
        }

        /// <summary>
        /// そのロボットに出ている Rescue の仕事を板から外す（往復を打ち切ったときなど）。
        /// まだ誰も取っていないものは板から消す。
        /// もう誰かが向かっている途中のものは消さず、相手の台だけ外す
        /// （歩き出したスタッフはそのまま着いて終える。消すとその人の仕事が宙に浮く）。
        /// </summary>
        public void CancelRescue(ServingRobotController robot)
        {
            for (int i = _jobs.Count - 1; i >= 0; i--)
            {
                StaffJob j = _jobs[i];
                if (j.kind != StaffJobKind.Rescue || j.robot != robot) continue;
                if (j.taken) j.robot = null;
                else _jobs.RemoveAt(i);
            }
            Recount();
        }

        // ---------------- あきらめた記録（B1）----------------

        /// <summary>あきらめた・移した・卓を空けたことを1行残す</summary>
        public void AddGiveUp(string who, string job, int tableId, Vector3 to, Vector3 at,
                              float waitedS, string action)
        {
            GiveUpRow r = new GiveUpRow();
            r.tS = NowS;
            r.who = who;
            r.job = job;
            r.tableId = tableId;
            r.toX = to.x;
            r.toZ = to.z;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.waitedS = waitedS;
            r.action = action;
            _giveUps.Add(r);

            if (action == "移した") warpedCount++;
            else if (action == "卓を空けた") tableFreedCount++;
            else gaveUpCount++;
        }

        // ---------------- 人がロボットをよけた記録 ----------------

        /// <summary>人がロボットの外周の近くまで来た1回を残す（接触に近い）</summary>
        public void AddRobotContact(Vector3 at, string who, bool careless, int robotId, float gapM)
        {
            ContactRow r = new ContactRow();
            r.tS = NowS;
            r.x = at.x;
            r.z = at.z;
            r.aisle = AisleNameAt(at);
            r.place = store != null ? store.PlaceNameAt(at) : "";
            r.who = who;
            r.careless = careless;
            r.robotId = robotId;
            r.gapM = gapM;
            _contacts.Add(r);
        }

        /// <summary>人がロボットを待ち始めた（入口で待つ・通路の中で下がる）</summary>
        public void AddRobotYieldWait() { robotYieldWaits++; }

        /// <summary>人がロボットを待っていた秒数を足す</summary>
        public void AddRobotYieldWaitSeconds(float dt) { robotYieldWaitSeconds += dt; }

        /// <summary>人が回り道した</summary>
        public void AddRobotYieldDetour() { robotYieldDetours++; }

        /// <summary>
        /// 店員優先の版の逃げ道：相手のロボットが別の理由で止まったまま
        /// staffFirstRobotStuckSec（60 秒）たって、店員が回り道した回数（v5）
        /// </summary>
        public int robotStuckDetours { get; private set; }

        public void AddRobotStuckDetour() { robotStuckDetours++; }

        // ---------------- 椅子を引いた回数（B6）----------------

        /// <summary>椅子を1脚引いた。きっかけごと・卓ごとに数える</summary>
        public void AddChairPull(string reason, int tableId)
        {
            chairPulls++;

            string key = string.IsNullOrEmpty(reason) ? "その他" : reason;
            int n;
            _chairPullsByReason[key] = _chairPullsByReason.TryGetValue(key, out n) ? n + 1 : 1;
            _chairPullsByTable[tableId] = _chairPullsByTable.TryGetValue(tableId, out n) ? n + 1 : 1;
        }

        /// <summary>その点がどの通路の上かを名前で返す。細い通路を先に見る</summary>
        public string AisleNameAt(Vector3 p)
        {
            if (store == null) return "";
            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;
        }

        // ---------------- 料理 ----------------

        /// <summary>
        /// 料理が1皿できて、受け渡し口に出る。
        ///
        /// 割り当ての決まりはサイトの試算 v2.7 にそろえる:
        ///   ・まずロボットを待つ。そのあいだスタッフは料理を持たず、案内・片付け・会計を続ける
        ///     （＝スタッフの仕事板に Serve を出さない。ロボットは _pass を直に見るので受け持てる）
        ///   ・robot_first_wait_s（common.robot_first_wait_s）を過ぎてもロボットが受け持たなければ、
        ///     そこからはロボットとスタッフのうち先に空いた方が運ぶ（ReleaseDishesToStaff が出す）
        ///   ・割れ物を人が運ぶ設定（fragile_by=staff）の倒れやすい料理と、ロボット0台のときは、
        ///     今までどおり、はじめからスタッフが運ぶ
        /// </summary>
        public DishOrder PutDish(int groupId, TableSlot slot, bool fragile)
        {
            DishOrder d = new DishOrder();
            d.id = _nextDishId++;
            d.groupId = groupId;
            d.tableIndex = slot.index;
            d.tableId = slot.tableId;
            d.readyS = NowS;
            d.fragile = fragile;
            d.staffFromStart = StaffCarriesFromStart(d);
            _pass.Add(d);

            if (d.staffFromStart)
            {
                // ロボットが受け持てない皿。待たせる意味がないので、はじめから仕事板に出す
                d.staffEligibleS = NowS;
                PostJob(StaffJobKind.Serve, groupId, slot, d, 1);
            }
            Recount();
            return d;
        }

        /// <summary>
        /// はじめからスタッフも運べる皿か（サイトの試算 v2.7）。
        ///   ・割れ物を人が運ぶ設定（fragile_by=staff）の、倒れやすい料理
        ///   ・ロボットが1台もいないとき
        ///   ・robot_first_wait_s が 0 以下のとき（v2.7 より前と同じ回し方に戻せる逃がし口）
        /// </summary>
        bool StaffCarriesFromStart(DishOrder d)
        {
            if (spec == null) return true;
            if (spec.robotFirstWaitSec <= 0f) return true;
            if (!spec.fragileByRobot && d.fragile) return true;
            return RobotCount == 0;
        }

        /// <summary>動いているロボットの数（null の枠は数えない）</summary>
        public int RobotCount
        {
            get
            {
                int n = 0;
                for (int i = 0; i < _robots.Count; i++) if (_robots[i] != null) n++;
                return n;
            }
        }

        /// <summary>
        /// ロボットだけを待つ時間を過ぎた皿を、スタッフの仕事板にも出す（サイトの試算 v2.7）。
        ///
        /// 待った時間は **料理ができた時（readyS）から**数える。
        /// 同じ通路に2台を集めない決まり（oneRobotPerAisle）で後に回した皿も、
        /// 受け渡し口に戻された皿も、この数え方は変えない
        /// （後に回された分だけ待ちが延びるのを、そのまま出すため）。
        /// </summary>
        void ReleaseDishesToStaff()
        {
            if (spec == null) return;
            float wait = spec.robotFirstWaitSec;

            int waitingForRobot = 0;
            for (int i = 0; i < _pass.Count; i++)
            {
                DishOrder d = _pass[i];
                if (d.arrivedS >= 0f) continue;

                // もうスタッフも運べる皿（はじめから運べる皿も含む）
                if (d.staffEligibleS >= 0f) continue;

                if (NowS - d.readyS < wait)
                {
                    // まだロボットだけを待つ時間の中。誰も持っていなければ「ロボットを待っている皿」
                    if (!d.taken) waitingForRobot++;
                    continue;
                }

                d.staffEligibleS = NowS;
                d.robotWaitExpired = true;

                // 誰かが持っている皿（ロボットが載せた・運んでいる途中）は、板に出さない。
                // 戻されたときに RequeueDish が出し直す
                if (d.taken) continue;

                TableSlot slot = SlotOf(d.tableIndex);
                if (slot != null) PostJob(StaffJobKind.Serve, d.groupId, slot, d, 1);
            }
            _dishesWaitingForRobot = waitingForRobot;
        }

        /// <summary>
        /// 慣らしのために、もう届いている皿として1枚作る（B3）。
        /// 受け渡し口にも仕事板にも出さず、待ち時間の記録にも入れない
        /// （慣らしの皿を 0 分として数えると dish_wait が小さく出てしまう）。
        /// </summary>
        public DishOrder SeedArrivedDish(int groupId, TableSlot slot)
        {
            DishOrder d = new DishOrder();
            d.id = _nextDishId++;
            d.groupId = groupId;
            d.tableIndex = slot != null ? slot.index : -1;
            d.tableId = slot != null ? slot.tableId : 0;
            d.readyS = NowS;
            d.arrivedS = NowS;
            d.fragile = false;
            return d;
        }

        /// <summary>届いた（ロボットでもスタッフでも）</summary>
        /// <summary>料理を持ち上げた。受け渡し口で待った時間・運んでいた時間を出すために時刻を残す</summary>
        void MarkTaken(DishOrder d)
        {
            if (d == null) return;
            d.taken = true;
            if (d.firstTakenS < 0f) d.firstTakenS = NowS;
            d.lastTakenS = NowS;
        }

        public void DishArrived(DishOrder d, bool byRobot)
        {
            if (d == null || d.arrivedS >= 0f) return;
            d.arrivedS = NowS;
            d.byRobot = byRobot;
            dishWaitMin.Add(Mathf.Max(0f, (d.arrivedS - d.readyS) / 60f));
            if (byRobot) robotDishes++; else staffDishes++;
            AddDishParts(d);
            _pass.Remove(d);
            RemoveServeJob(d);
            Recount();
        }

        /// <summary>
        /// 届けられなかった料理を、受け渡し口の待ち行列の先頭に戻す。
        /// 次に空いたロボットかスタッフが運ぶ。
        /// </summary>
        /// <summary>
        /// 届いた1皿の、届くまでの内訳を足す。
        ///   受け渡し口で待った ＋ 運んでいた ＋ 途中で戻されて失った ＝ できてから届くまで
        /// </summary>
        void AddDishParts(DishOrder d)
        {
            if (d == null || d.arrivedS < 0f || d.readyS < 0f) return;

            float total = Mathf.Max(0f, d.arrivedS - d.readyS);
            float passWait = d.firstTakenS >= 0f ? Mathf.Max(0f, d.firstTakenS - d.readyS) : total;
            float carry = d.lastTakenS >= 0f ? Mathf.Max(0f, d.arrivedS - d.lastTakenS) : 0f;

            DishParts p = new DishParts();
            p.byRobot = d.byRobot;
            p.passWaitS = passWait;
            p.carryS = carry;
            p.requeueS = Mathf.Max(0f, total - passWait - carry);
            p.requeued = d.requeued;
            _dishParts.Add(p);

            // ---- 皿ごとの記録（dishes.csv・サイトの試算 v2.7）----
            // 「2分を過ぎて人が拾った皿」は、ロボットだけを待つ時間を過ぎてから
            // スタッフが運んだもの。はじめからスタッフが運ぶ皿
            // （fragile_by=staff・ロボット0台）は、待つ決まりが当てはまらないので数えない
            bool late = d.robotWaitExpired && !d.staffFromStart && !d.byRobot;

            DishRow row = new DishRow();
            row.dishId = d.id;
            row.tableId = d.tableId;
            row.groupId = d.groupId;
            row.fragile = d.fragile;
            row.readyS = d.readyS;
            row.firstTakenS = d.firstTakenS;
            row.arrivedS = d.arrivedS;
            row.byRobot = d.byRobot;
            row.passWaitS = passWait;
            row.carryS = carry;
            row.requeueS = p.requeueS;
            row.requeued = d.requeued;
            row.staffFromStart = d.staffFromStart;
            row.robotWaitExpired = d.robotWaitExpired;
            row.lateStaffPickup = late;
            row.deferredForAisle = d.deferredForAisle;
            _dishRows.Add(row);

            if (late) lateStaffDishes++;
        }

        public void RequeueDish(DishOrder d)
        {
            RequeueDish(d, 0, "");
        }

        /// <summary>戻した理由も残す（B8）。robotId 0 はロボット以外</summary>
        public void RequeueDish(DishOrder d, int robotId, string why)
        {
            if (d == null || d.arrivedS >= 0f) return;
            d.taken = false;
            d.requeued++;
            requeuedDishes++;

            RequeueRow r = new RequeueRow();
            r.tS = NowS;
            r.dishId = d.id;
            r.tableId = d.tableId;
            r.robotId = robotId;
            r.fragile = d.fragile;
            r.readyS = d.readyS;
            r.requeuedSoFar = d.requeued;
            r.why = string.IsNullOrEmpty(why) ? "着かなかった" : why;
            _requeues.Add(r);

            _pass.Remove(d);
            _pass.Insert(0, d);

            TableSlot slot = SlotOf(d.tableIndex);
            RemoveServeJob(d);

            // サイトの試算 v2.7：まだロボットだけを待つ時間の中なら、板に出さずロボットを待つ。
            // 待った時間は料理ができた時から数えるので、戻されても数え直さない
            // （過ぎていれば ReleaseDishesToStaff が staffEligibleS を入れている）
            if (slot != null && d.staffEligibleS >= 0f)
                PostJobFront(StaffJobKind.Serve, d.groupId, slot, d, 1);
            Recount();
        }

        TableSlot SlotOf(int index)
        {
            return index >= 0 && index < _tables.Count ? _tables[index] : null;
        }

        // ---------------- スタッフの仕事板 ----------------

        public StaffJob PostJob(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = NewJob(kind, groupId, slot, dish, priority);
            _jobs.Add(j);
            Recount();
            return j;
        }

        StaffJob PostJobFront(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = NewJob(kind, groupId, slot, dish, priority);
            _jobs.Insert(0, j);
            Recount();
            return j;
        }

        StaffJob NewJob(StaffJobKind kind, int groupId, TableSlot slot, DishOrder dish, int priority)
        {
            StaffJob j = new StaffJob();
            j.kind = kind;
            j.priority = priority;
            j.groupId = groupId;
            j.tableIndex = slot != null ? slot.index : -1;
            j.tableId = slot != null ? slot.tableId : 0;
            j.dish = dish;
            j.postedS = NowS;
            return j;
        }

        /// <summary>
        /// 次にやる仕事を1つ取る。優先度が小さいものが先、同じなら出た順。
        /// 配膳の仕事は、料理がまだ誰も持っていないものだけ。
        /// 立ち往生（Rescue）は優先度 0 なので、ほかの仕事より先に取られる。
        /// </summary>
        public StaffJob TakeNextJob()
        {
            StaffJob best = null;
            for (int i = 0; i < _jobs.Count; i++)
            {
                StaffJob j = _jobs[i];
                if (j.taken) continue;
                if (j.kind == StaffJobKind.Serve && (j.dish == null || j.dish.taken || j.dish.arrivedS >= 0f)) continue;
                // 店員側（レジの内側）は1人ぶんなので、会計はひとつずつ
                if (j.kind == StaffJobKind.Pay && RegisterBackBusy) continue;
                if (best == null || j.priority < best.priority) best = j;
                if (best != null && best.priority <= 0) break;   // 優先0（立ち往生）が見つかれば、それが先
            }
            if (best == null) return null;
            best.taken = true;
            best.takenS = NowS;
            if (best.kind == StaffJobKind.Pay) RegisterBackBusy = true;
            RecordJobWait(best);
            if (best.kind == StaffJobKind.Serve && best.dish != null) MarkTaken(best.dish);
            Recount();
            return best;
        }

        /// <summary>仕事が終わった</summary>
        public void FinishJob(StaffJob job)
        {
            if (job == null) return;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            _jobs.Remove(job);
            Recount();
            if (job.onDone != null) job.onDone();
        }

        /// <summary>
        /// 仕事を板から外すだけ（終わったときの呼び出しはしない）。
        /// 卓を強引に空けたときなど、出した側がもう待っていないときに使う。
        /// </summary>
        public void FinishJobSilently(StaffJob job)
        {
            if (job == null) return;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            job.onDone = null;
            _jobs.Remove(job);
            if (job.kind == StaffJobKind.Serve && job.dish != null) job.dish.taken = false;
            Recount();
        }

        /// <summary>仕事を手放す（届けられなかったときなど）</summary>
        public void AbandonJob(StaffJob job)
        {
            if (job == null) return;
            job.taken = false;
            if (job.kind == StaffJobKind.Pay) RegisterBackBusy = false;   // 店員側を空ける
            if (job.kind == StaffJobKind.Serve && job.dish != null) job.dish.taken = false;
            Recount();
        }

        void RemoveServeJob(DishOrder d)
        {
            for (int i = _jobs.Count - 1; i >= 0; i--)
                if (_jobs[i].kind == StaffJobKind.Serve && _jobs[i].dish == d) _jobs.RemoveAt(i);
        }

        // ---------------- ロボットに渡す ----------------

        /// <summary>
        /// 空いているロボットに、受け渡し口の料理を段の数までまとめて渡す。
        /// 近い卓から回る（試算の _robot_run と同じ）。
        /// </summary>
        void AssignRobots()
        {
            if (spec == null || store == null) return;

            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                if (r.CurrentPhase != ServingRobotController.Phase.Idle &&
                    r.CurrentPhase != ServingRobotController.Phase.Done) continue;

                List<DishOrder> batch = PickBatch(r);
                if (batch.Count == 0) continue;

                // 止まる所と皿を、同じ並びで組む（依頼 v8 の 1）。
                // SlotOf が null の皿は止まる所を作れないので、どちらからも外す
                List<ServingRobotController.RunStop> stops = new List<ServingRobotController.RunStop>();
                List<DishOrder> stopDishes = new List<DishOrder>();
                for (int k = 0; k < batch.Count; k++)
                {
                    TableSlot s = SlotOf(batch[k].tableIndex);
                    if (s == null) continue;
                    ServingRobotController.RunStop stop = new ServingRobotController.RunStop();
                    stop.tableId = s.tableId;
                    stop.point = s.deliveryPoint;
                    stop.aisleEntrance = s.aisleEntrance;
                    stop.aisleZone = s.aisleZone;
                    stops.Add(stop);
                    stopDishes.Add(batch[k]);
                }
                if (stops.Count == 0) continue;

                _carrying[r] = batch;
                _stopDishes[r] = stopDishes;
                r.StartRun(stops, store.PassPoint, NowS);
            }
        }

        /// <summary>段の数までまとめる。近い卓から順に並べる</summary>
        List<DishOrder> PickBatch(ServingRobotController self)
        {
            List<DishOrder> batch = new List<DishOrder>();
            Vector3 pass = store.PassPoint;
            int trays = Mathf.Max(1, spec.trays);

            // 同じ通路に2台を集めない（v5・A）。
            // ほかの台が向かっている・中にいる通路の卓は、この回では受け持たない。
            // 1往復で3卓まとめるときも入れない（この for で毎回見る）
            bool oneEach = robotSpec != null && robotSpec.oneRobotPerAisle;
            HashSet<int> busy = oneEach ? AislesTakenByOthers(self) : null;

            for (int i = 0; i < _pass.Count && batch.Count < trays; i++)
            {
                DishOrder d = _pass[i];
                if (d.taken || d.arrivedS >= 0f) continue;
                if (!spec.fragileByRobot && d.fragile) continue;   // 人だけが運ぶ料理
                if (busy != null)
                {
                    TableSlot slot = SlotOf(d.tableIndex);
                    if (slot != null && slot.aisleIndex >= 0 && busy.Contains(slot.aisleIndex))
                    {
                        // 数えるのは1皿につき1回だけ（毎コマ数えると意味のない大きさになる）
                        if (!d.deferredForAisle) { d.deferredForAisle = true; aisleDeferrals++; }
                        continue;
                    }
                }
                batch.Add(d);
            }

            batch.Sort(delegate (DishOrder a, DishOrder b)
            {
                TableSlot sa = SlotOf(a.tableIndex), sb = SlotOf(b.tableIndex);
                float da = sa != null ? Flat(sa.deliveryPoint - pass) : 0f;
                float db = sb != null ? Flat(sb.deliveryPoint - pass) : 0f;
                return da.CompareTo(db);
            });

            for (int i = 0; i < batch.Count; i++)
            {
                MarkTaken(batch[i]);
                RemoveServeJob(batch[i]);
            }
            return batch;
        }

        static float Flat(Vector3 d) { d.y = 0f; return d.magnitude; }

        /// <summary>
        /// ロボットが止まる所（卓）に着いた（依頼 v8 の 1）。
        ///
        /// サイトの試算と同じで、**卓に着いた時点でその皿は届いた**。
        /// 受け渡しの 25 秒（RobotSpec.tableDwellSec）はこのあとなので、
        /// 客は受け渡し口へ戻るのを待たずに食べ始められる
        /// （前は往復の最後にまとめて届いたにしていて、料理の待ちが
        ///  1皿あたり 1.0〜1.3 分長く出ていた）。
        /// </summary>
        void OnRobotStopArrived(ServingRobotController r, int stopIndex)
        {
            List<DishOrder> stopDishes;
            if (!_stopDishes.TryGetValue(r, out stopDishes) || stopDishes == null) return;
            if (stopIndex < 0 || stopIndex >= stopDishes.Count) return;

            DishArrived(stopDishes[stopIndex], true);
        }

        /// <summary>
        /// ロボットの1走りぶんの片づけが終わった（依頼 v8 の 2）。
        ///
        /// ExperimentRunner は trips.csv を書くときに LastBatchOf を読むので、
        /// ServingRobotController.TripCompleted を直に受けると、呼ばれる順番によって
        /// **1つ前の往復の皿**を読んでしまっていた。
        /// ここで片づけを終えてから出すこのイベントを受ければ、順番に頼らずに済む。
        /// </summary>
        public event System.Action<ServingRobotController> RunRecorded;

        /// <summary>
        /// ロボットの1走りが終わった。
        /// 卓に着いた皿はもう届いているので何もしない。
        /// まだ届いていない皿（arrivedS &lt; 0）だけを受け渡し口の先頭へ戻す。
        /// </summary>
        void OnRobotRunDone(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (!_carrying.TryGetValue(r, out batch) || batch == null) return;

            _lastBatch[r] = batch;      // trips.csv を書くために残す
            _carrying[r] = new List<DishOrder>();
            _stopDishes[r] = new List<DishOrder>();

            // なぜ届けられなかったか。ロボットが持っている結果から決める。
            // v5 3章：動けなくなってもスタッフが動かせば走りは続くので、
            // 「動けなくなった」は打ち切り・取りやめより後ろに置く（往復が終わった理由が先）
            string why = r.TimedOut ? "打ち切り"
                       : r.Aborted ? "取りやめ"
                       : r.Stuck ? "動けなくなった"
                       : "着かなかった";

            for (int i = 0; i < batch.Count; i++)
            {
                // 卓に着いた時点で DishArrived してあるので、arrivedS で見分ける
                // （StopsCompleted の数で数えない。止まる所と皿の並びは対応表で持っている）
                if (batch[i].arrivedS >= 0f) continue;
                RequeueDish(batch[i], r.robotId, why);
            }

            // 片づけが終わってから知らせる（ここで LastBatchOf を読んでよい）
            if (RunRecorded != null) RunRecorded(r);
        }

        /// <summary>そのロボットがいま載せている料理</summary>
        public IList<DishOrder> CarriedBy(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (r != null && _carrying.TryGetValue(r, out batch)) return batch;
            return new List<DishOrder>();
        }

        /// <summary>直前の1走りで載せていた料理（trips.csv を書くのに使う）</summary>
        public IList<DishOrder> LastBatchOf(ServingRobotController r)
        {
            List<DishOrder> batch;
            if (r != null && _lastBatch.TryGetValue(r, out batch) && batch != null) return batch;
            return new List<DishOrder>();
        }
    }
}
```



## サイトの店の形の変更（p2m-site）

### commit 1ee9d8e：ロボットの地図を書き出す（tools/ の差分）

店の形・解析・Unity 用の JSON は、版の番号と規則の説明文だけが変わったので、tools/ の差分だけを載せます。地図の JSON（public/sim/layouts/unity/*_map.json）はサイトにあります。

```diff
commit 1ee9d8e8590c9391280774c292ee266b918d26cc
店の形：Unity のロボットが自分で道を引くための地図を書き出す（版 2fa10547a2b7）


diff --git a/tools/sim_layouts/README.md b/tools/sim_layouts/README.md
index 22d2ece..1aa11e9 100644
--- a/tools/sim_layouts/README.md
+++ b/tools/sim_layouts/README.md
@@ -30,7 +30,8 @@
 - 場所の点：受け渡し口の前・下げ膳の返却（受け渡し口と同じ）・入口・待合・レジ（レジ内の反対側）と並ぶ点・レジ内・トイレの入口の前・ドリンクバーの前と立つ点・並ぶ点・ロボットの待機場所（1台ずつ）・スタッフが待つ点
 - スタッフが待つ点：パントリー（と接する壁）の客席側の辺にそって壁から0.25m、0.6mおき。外側にロボットのふだんの道（1.0m）が残る所だけ。受け渡し口に近い順に6つ（仮の規則）
 - 並ぶ点・立つ点は、障害物から0.25m未満の所と細い通路には置かない
-- ロボットの道順（`routes`）：受け渡し口の前→各卓の止まる位置（`from_pass`）と、卓→卓（`between`。"T01|T02" は T01 から T02、逆向きは点を逆にたどる）。ふだん走らせる幅の道で行けるならその道、行けなければ通れる幅の道（試算と同じ規則。`use` が normal か narrow。試算の卓の分け方と一致することをテストで確かめる）。マス目の最短の道を、通れる範囲でまっすぐにつないだ曲がり角の点の並び。Unity のロボットはこの点を順にたどる（Unity で道を決めない）。2026-10-22、Unity で狭い通路の区画を NavMesh に書き込んだら NavMesh が壊れたため、この形にした
+- ロボットの道順（`routes`）：受け渡し口の前→各卓の止まる位置（`from_pass`）と、卓→卓（`between`。"T01|T02" は T01 から T02、逆向きは点を逆にたどる）。ふだん走らせる幅の道で行けるならその道、行けなければ通れる幅の道（試算と同じ規則。`use` が normal か narrow。試算の卓の分け方と一致することをテストで確かめる）。マス目の最短の道を、通れる範囲でまっすぐにつないだ曲がり角の点の並び。2026-10-22 から Unity のロボットはこの点を順にたどっていたが、2026-10-24 から下の地図の上で自分で道を引く形に変える（線に縛ると、行きと帰りが同じ線になり、ロボットどうしが正面から出会って回り込めなかったため）。道順は、回り込みで延びた距離を測る物差しと、前の動き方に戻す切り替えのために残す
+- ロボットの地図（`unity/{名前}_map.json`。2026-10-24）：試算が卓までの距離を測るのと同じ2.5cmのマス目で、マスごとに「ふだんの道（1.0m）が通れる＝2／通れる幅（0.8m）の道だけ通れる＝1／通れない＝0」（`free_class`）と、いちばん近い障害物までの距離（`clear_mm`）を入れたもの。分け方は試算と同じ式（`Grid.free`）。会計の列は通れない。待機場所は床として入れる（止まっているロボットは Unity で動くものとして扱う。待機場所の真ん中は壁から0.4m前後なので1のマス）。Unity のロボットはこの上で自分で道を引く（行き先までふだんの道で行けるならその道だけ、行けなければ狭い道も使う。試算と同じ規則）。地図だけで試算の卓の分け方（robot・narrow）が再現できることをテストで確かめる。中身は gzip で縮めた base64（読み方は `unity_export.unpack_map`）
 - 会計の列（`robot_keepout`）：ロボットの道にしない矩形。人は通れるので、Unity では部屋や壁として建てない（道順はこれをよけて作ってある）
 - まだないもの：② の4人席・丸テーブル・6人の長テーブルの椅子と座る位置（parts が null）、人がロボットをよける逃げ場（refuges）
 
diff --git a/tools/sim_layouts/famires_v3.py b/tools/sim_layouts/famires_v3.py
index 89e8738..2c28568 100644
--- a/tools/sim_layouts/famires_v3.py
+++ b/tools/sim_layouts/famires_v3.py
@@ -1010,6 +1010,12 @@ def export_unity(L, res_t, ctx, nm, out, gen_id):
                            standby_slots(L["robot_standby"]), FURNITURE_TYPES, meta)
     (out / "unity").mkdir(exist_ok=True)
     json.dump(U, open(out / "unity" / f"{nm}.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
+    # ロボットの地図（2026-10-24）：同じマス目で、待機場所を床にしたもの。ロボットはこの上で自分で道を引く
+    gm = Grid(L, SETTINGS["res"], standby=[])
+    M = unity_export.build_map(gm, SETTINGS["normal_width"], ROBOT_MODELS[SETTINGS["model"]]["pass_width"],
+                               dict(generation=gen_id, units="m", source=f"{nm}.json と同じ店の形・同じマス目（settings.res）から作った",
+                                    robot_model=meta["robot_model"]["name"]))
+    json.dump(M, open(out / "unity" / f"{nm}_map.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
     return U
 
 
diff --git a/tools/sim_layouts/test_famires_v3.py b/tools/sim_layouts/test_famires_v3.py
index 9d5274c..b43624b 100644
--- a/tools/sim_layouts/test_famires_v3.py
+++ b/tools/sim_layouts/test_famires_v3.py
@@ -384,6 +384,38 @@ for nm, fn, width in (("p1", gen.p1_efficient_corner, 1.2), ("p5", gen.p5_cramme
     expect(f"{nm}：会計に立つ点・並ぶ点は会計の列の中", all(any(k[0] <= p["point"][0] <= k[2] and k[1] <= p["point"][1] <= k[3] for k in ko)
                                                      for p in U["places"] if p["kind"] in ("Register", "RegisterQueue")))
 
+# ---- ロボットの地図（unity/{名前}_map.json。2026-10-24）
+from scipy.sparse.csgraph import connected_components
+for nm, fn in (("p1", gen.p1_efficient_corner), ("p5", gen.p5_crammed), ("p0", famires_p0.p0_memory)):
+    L = v.prepare(fn())
+    ctx = {}
+    res, _ = v.analyze(L, v.SETTINGS["res"], ctx)
+    g = ctx["grid"]
+    nw, pw = v.SETTINGS["normal_width"], v.ROBOT_MODELS[v.SETTINGS["model"]]["pass_width"]
+    gm = v.Grid(L, v.SETTINGS["res"], standby=[])
+    M = ux.build_map(gm, nw, pw, {})
+    cls, mm = ux.unpack_map(M)
+    expect(f"{nm}：地図を読み戻すと、マス目の数が同じ", cls.shape == (g.ny, g.nx) == (M["ny"], M["nx"]))
+    expect(f"{nm}：地図を2回作ると同じ文字列（毎回同じ）", ux.build_map(gm, nw, pw, {})["free_class"] == M["free_class"])
+    expect(f"{nm}：障害物までの距離は、計算の値と0.5mm以内", float(abs(mm / 1000.0 - np.minimum(gm.clear, 65.535)).max()) <= 0.0005 + 1e-9)
+    expect(f"{nm}：試算でふだんの道が通れるマスは、地図でも2（待機場所を床にした分だけ増える）",
+           bool((cls[g.free(nw)] == 2).all()) and bool((cls[g.free(pw)] >= 1).all()))
+    expect(f"{nm}：会計の列のマスは、地図では通れない",
+           all(cls[gm.cell((k[0] + k[2]) / 2, (k[1] + k[3]) / 2)] == 0 for k in v.checkout_keepout(L)))
+    # 地図だけで、試算と同じ卓の分け方になるか（受け渡し口の前から、2のマスだけでつながる卓＝ふだんの道の卓）
+    _, lab2 = connected_components(gm.graph(cls == 2), directed=False)
+    _, lab1 = connected_components(gm.graph(cls >= 1), directed=False)
+    c = lambda x, y: (lambda q: q[0] * gm.nx + q[1])(gm.cell(x, y))
+    s = c(*L["start"])
+    expect(f"{nm}：地図の上でも、ふだんの道で行ける卓は試算の robot の卓と同じ、狭い道でだけ行ける卓は narrow の卓と同じ",
+           all(((lab2[s] == lab2[c(*a["stop"])]) == (a["category"] == "robot")) and
+               ((lab1[s] == lab1[c(*a["stop"])]) == (a["category"] in ("robot", "narrow")))
+               for a in res if a.get("stop")))
+    slots = v.standby_slots(L["robot_standby"])
+    # 待機場所は壁ぎわに止める形（真ん中が壁から0.4m前後）なので、最後は狭い道（1）のマスになる
+    expect(f"{nm}：どの待機場所の真ん中にも、受け渡し口の前から地図の上で行ける（通れる幅の道）",
+           all(lab1[s] == lab1[c((r[0] + r[2]) / 2, (r[1] + r[3]) / 2)] for r in slots))
+
 # 確かめの仕組みの確かめ：会計の列がなければ、②はロボットが会計の列の前を横切り、確かめに引っかかる
 _ko = v.checkout_keepout
 v.checkout_keepout = lambda L: []
diff --git a/tools/sim_layouts/unity_export.py b/tools/sim_layouts/unity_export.py
index 168c9ef..41d4fcb 100644
--- a/tools/sim_layouts/unity_export.py
+++ b/tools/sim_layouts/unity_export.py
@@ -22,7 +22,14 @@
 - 席に着くために立つ点は、障害物から0.2m（人の半径）以上離れた所だけ
 - 会計の列（robot_keepout）：レジの客の側で、ロボットの道にしない矩形（famires_v3.checkout_keepout）。人は通れるので、部屋や壁としては建てない。
   ロボットの道順（routes）はこれをよけて作ってある。Unity では見せるための目印として使ってよい
+- ロボットの地図（unity/{名前}_map.json。2026-10-24）：試算が卓までの距離を測るのと同じマス目（settings.res）の、マスごとの値。
+  free_class はマスの分け方（2＝ふだん走らせる幅 normal_width の道が通れる、1＝通れる幅 pass_width の道だけ通れる、0＝通れない）。
+  clear_mm はマスの中心からいちばん近い障害物（壁・部屋・卓・会計の列）までの距離（mm）。
+  分け方は試算と同じ式（famires_v3.Grid.free）で、ロボットはこの地図の上で自分で道を引く（ふだんの道で行けるならそこ、行けなければ狭い道。試算と同じ規則）。
+  待機場所は床として入れる（止まっているロボットは、Unity で動くものとして扱う）。routes は残す（回り込みで延びた距離を測る物差し）
 """
+import base64
+import gzip
 import math
 
 import numpy as np
@@ -517,3 +524,36 @@ def build(L, analysis_tables, grid, dist, dist_pass, sides, standby_slots, FT, m
                 rooms=[dict(label=r["label"], kind=r["kind"], rect=r["rect"], **WALKABLE.get(r["label"], {"walkable": False}))
                        for r in L["rooms"] if r["kind"] != "outside"],
                 doors=L.get("doors", []), robot_keepout=[dict(label="会計の列（ロボットは通らない）", rect=list(r)) for r in L.get("robot_keepout", [])], aisles=aisles, tables=tables, places=places(L, grid, standby_slots, aisles))
+
+
+MAP_SCHEMA = "famires-unity-map/0.1"
+
+
+def _pack(a):
+    """配列を、行（y=0 の手前から）ごとに並べた生のバイト列 → gzip → base64 の文字列にする（gzip の時刻は 0。同じ中身なら毎回同じ文字列）"""
+    return base64.b64encode(gzip.compress(np.ascontiguousarray(a).tobytes(), mtime=0)).decode("ascii")
+
+
+def unpack_map(M):
+    """書き出した地図を読み戻す（確かめ用。Unity でも同じ手順で読む）"""
+    ny, nx = M["ny"], M["nx"]
+    cls = np.frombuffer(gzip.decompress(base64.b64decode(M["free_class"])), dtype=np.uint8).reshape(ny, nx)
+    mm = np.frombuffer(gzip.decompress(base64.b64decode(M["clear_mm"])), dtype="<u2").reshape(ny, nx)
+    return cls, mm
+
+
+def build_map(grid, normal_width, pass_width, meta):
+    """ロボットの地図。grid は待機場所を床にした famires_v3.Grid（同じ res）"""
+    f_n, f_p = grid.free(normal_width), grid.free(pass_width)
+    cls = np.where(f_n, 2, np.where(f_p, 1, 0)).astype(np.uint8)
+    mm = np.clip(np.round(grid.clear * 1000.0), 0, 65535).astype("<u2")
+    return dict(
+        meta=dict(meta, schema_version=MAP_SCHEMA),
+        res=grid.res, nx=grid.nx, ny=grid.ny,
+        normal_width=normal_width, pass_width=pass_width,
+        layout=("マス (ix, iy) の中心は x=(ix+0.5)*res、y=(iy+0.5)*res（店の外壁の内側の左下が原点。Unity では x→X、y→Z）。"
+                "配列は iy=0（手前）の行から、1行は ix=0 から nx 個。番号は iy*nx+ix"),
+        encoding="gzip（時刻0）で縮めて base64。free_class は 1マス1バイト、clear_mm は 1マス2バイト（小さい桁が先）",
+        free_class_values={"2": "ふだん走らせる幅の道が通れる", "1": "通れる幅の道だけ通れる", "0": "通れない"},
+        counts={"2": int((cls == 2).sum()), "1": int((cls == 1).sum()), "0": int((cls == 0).sum())},
+        free_class=_pack(cls), clear_mm=_pack(mm))
```

### tools/sim_layouts/famires_v3.py の出力（版 8a5b2f6a016a）

```text
[OK] p0_memory: {'name': '② 定番の郊外型', 'seats': 88, 'tables': 19, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 19, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.6, 'robot_dist_max': 13.78, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p1_efficient_corner: {'name': '③ 効率重視型・入口が左下の角', 'seats': 160, 'tables': 42, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 42, 'robot_seats': 160, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 10.6, 'robot_dist_max': 20.14, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p3_relaxed_center: {'name': '⑤ ゆったり型・入口が正面の中央', 'seats': 200, 'tables': 46, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 46, 'robot_seats': 200, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 13.7, 'robot_dist_max': 23.17, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p4_relaxed_side: {'name': '⑥ ゆったり型・入口が左の壁', 'seats': 158, 'tables': 38, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 38, 'robot_seats': 158, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 10.7, 'robot_dist_max': 18.38, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
[OK] p5_crammed: {'name': '⑦ ③の列の間の通路を95cmに（卓と席数は③と同じ）', 'seats': 160, 'tables': 42, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 18, 'robot_seats': 64, 'narrow_tables': 24, 'narrow_seats': 96, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 10.8, 'robot_dist_max': 20.17, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
```

### tools/sim_layouts/test_famires_v3.py の出力（版 8a5b2f6a016a）

```text
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.0）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.0）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.0）
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.013）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.013）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.013）
OK  80cmの通路はBellaBotで通れる（マス目0.025・ずらし0.031）
OK  77cmの通路はBellaBotで通れない（マス目0.025・ずらし0.031）
OK  75cmの通路はServiで通れる（マス目0.025・ずらし0.031）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.0）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.0）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.0）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.013）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.013）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.013）
OK  80cmの通路はBellaBotで通れる（マス目0.0125・ずらし0.031）
OK  77cmの通路はBellaBotで通れない（マス目0.0125・ずらし0.031）
OK  75cmの通路はServiで通れる（マス目0.0125・ずらし0.031）
OK  横向きのボックスの左右（ベンチの背）は座る側の候補にしない
OK  縦向きのボックスの上下（ベンチの背）は座る側の候補にしない
OK  店の外にはみ出した卓を見つける
OK  トイレの入口の前の壁を見つける
OK  トイレの壁でへだてた1mの席は違反にしない
OK  壁がなければ、トイレからちょうど1mの席を見つける
OK  卓に食い込んだ壁を見つける
OK  ゆったり型の仕切り（背もたれの上）は違反にしない
OK  座る側の前をふさがれた卓を見つける
OK  出発点がふさがれていたら止める
OK  待合がないのを見つける
OK  ⑦は③と同じ卓の数・種類・席数
OK  L字席（flip=False・mirror=False）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=False・mirror=True）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=True・mirror=False）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  L字席（flip=True・mirror=True）の止まってよい辺は、開いた角とソファの端0.6mまで
OK  ⓪T02 の止まる位置は、開いた角か、そこからソファの端に沿って0.6m以内
OK  ⓪の待機場所は、1台ずつ受け渡し口の前まで出られる
OK  2人席（ベンチがbottom）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがtop）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがleft）は、ベンチの側とベンチの横を止まる辺にしない
OK  2人席（ベンチがright）は、ベンチの側とベンチの横を止まる辺にしない
OK  直す前の⓪の待機場所（2.2×0.7mに3台）を見つける
OK  待機場所の台数 100 を見つける
OK  待機場所の台数 0 を見つける
OK  待機場所の台数 -1 を見つける
OK  待機場所の台数 '3' を見つける
OK  待機場所の台数 2.5 を見つける
OK  待機場所の台数 True を見つける
OK  待機場所の前をふさぐと、真ん中の台だけ「出られない」を見つける
OK  待機場所の前と両横をふさぐと、3台とも「出られない」を見つける
OK  反例1：レジとレジ内を消すと見つける
OK  反例2：トイレの壁をほぼ全部外すと「切れ目」を見つける
OK  反例3：トイレの入口の前を「トイレの壁」という名前の壁でふさいでも見つける
OK  反例4：家具の真ん中に置いた接合扱いの仕切りを見つける
OK  反例5：待機場所100台を見つける
OK  反例6：⓪の建物の外を消すと、輪郭と合わないのを見つける
OK  反例7：輪郭を2m四方にすると見つける
OK  ⓪の輪郭から欠けを消すと、建物の外と合わないのを見つける
OK  今の店 p0_memory は決まりを守っている
OK  今の店 p1_efficient_corner は決まりを守っている
OK  今の店 p2_efficient_narrow は決まりを守っている
OK  今の店 p3_relaxed_center は決まりを守っている
OK  今の店 p4_relaxed_side は決まりを守っている
OK  今の店 p5_crammed は決まりを守っている
OK  ベンチの背に立つ仕切りは認める
OK  ベンチの背でも0.08mより深い食い込みは認めない
OK  トイレの目隠し：中心の線だけ当たる短い壁では違反にする（4本の線）
OK  トイレの目隠し：4本とも当たる壁なら違反にしない
OK  線分が厚さ2mmの壁を横切るのを見つける
OK  線分の端が壁に触れるだけなら、通ったとみなさない
OK  線分が壁から離れていれば、通ったとみなさない
OK  201番目だけ見通せる止まる位置も見つける
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []
OK  成功したときは、layout・analysis・summary に同じ版の番号が入る
[NG] p2_efficient_narrow: ④ 効率重視型・狭い店・入口が右の壁
   - 入口まわり: レジがない

違反があるので、/tmp/tmp8n85uhsz には何も書いていない（前の版のまま）
OK  違反があると止まり、出力先のファイルは前の版のまま（作業用のフォルダも残らない）
[OK] p2_efficient_narrow: {'name': '④ 効率重視型・狭い店・入口が右の壁', 'seats': 88, 'tables': 28, 'model': 'BellaBot', 'pass_width': 0.8, 'normal_width': 1.0, 'robot_tables': 28, 'robot_seats': 88, 'narrow_tables': 0, 'narrow_seats': 0, 'human_tables': 0, 'human_seats': 0, 'robot_dist_mean': 8.4, 'robot_dist_max': 14.02, 'human_walk_total': 0.0, 'human_walk_max': None}  マス目の差: []

出力先へ入れ替える途中で失敗したので、/tmp/tmp8n85uhsz を前の版に戻した
OK  入れ替えの途中で失敗すると、出力先は前の版に戻る（作業用のフォルダも残らない）
OK  反例A：トイレの壁の1cmの切れ目を見つける
OK  反例B：トイレの入口の内側の1cmのふさぎを見つける
OK  反例C：トイレの入口の前の奥行き0.1mを見つける
OK  反例D：輪郭の幅1cmの切れ込み（建物の外なし）を見つける
OK  輪郭に斜めの辺があれば止める
OK  出入り口Aを壁でふさぐと見つける
OK  p1：細い通路は3本で、幅はどれも1.2m
OK  p1：卓の数・止まる位置・距離が analysis と同じ
OK  p1：細い通路を通る卓は、止まる位置がその通路の区画の中
OK  p1：細い通路の入口は、受け渡し口の前から行ける端
OK  p1：全部の卓に部品（天板・座る位置）があり、座る位置の数が席数と同じ
OK  p1：場所の点がそろっている
OK  p1：スタッフが待つ点・並ぶ点・立つ点は、障害物から0.2m以上離れ、細い通路に入らない
OK  p1：席に着く点は障害物から0.2m以上離れ、どの卓にも1つ以上ある
OK  p1：レジ内は通れる部屋（スタッフだけ）、ほかの部屋は通れない箱
OK  p1：受け渡し口から全卓・卓から卓の全組に道順がある
OK  p1：ふだんの道か狭い道かは、試算の卓の分け方と同じ（robot→normal、narrow→narrow）
OK  p1：道順は受け渡し口の前から始まり、止まる位置で終わる
OK  p1：道順のどの区間も、その幅で通れるマス目だけを通る
OK  p1：会計に立つ点・並ぶ点は、ロボットの道から0.75m以上
OK  p1：会計の列が1つあり、ロボットは通れず、人は通れる
OK  p1：会計に立つ点・並ぶ点は会計の列の中
OK  p5：細い通路は3本で、幅はどれも0.95m
OK  p5：卓の数・止まる位置・距離が analysis と同じ
OK  p5：細い通路を通る卓は、止まる位置がその通路の区画の中
OK  p5：細い通路の入口は、受け渡し口の前から行ける端
OK  p5：全部の卓に部品（天板・座る位置）があり、座る位置の数が席数と同じ
OK  p5：場所の点がそろっている
OK  p5：スタッフが待つ点・並ぶ点・立つ点は、障害物から0.2m以上離れ、細い通路に入らない
OK  p5：席に着く点は障害物から0.2m以上離れ、どの卓にも1つ以上ある
OK  p5：レジ内は通れる部屋（スタッフだけ）、ほかの部屋は通れない箱
OK  p5：受け渡し口から全卓・卓から卓の全組に道順がある
OK  p5：ふだんの道か狭い道かは、試算の卓の分け方と同じ（robot→normal、narrow→narrow）
OK  p5：道順は受け渡し口の前から始まり、止まる位置で終わる
OK  p5：道順のどの区間も、その幅で通れるマス目だけを通る
OK  p5：会計に立つ点・並ぶ点は、ロボットの道から0.75m以上
OK  p5：会計の列が1つあり、ロボットは通れず、人は通れる
OK  p5：会計に立つ点・並ぶ点は会計の列の中
OK  p1：地図を読み戻すと、マス目の数が同じ
OK  p1：地図を2回作ると同じ文字列（毎回同じ）
OK  p1：障害物までの距離は、計算の値と0.5mm以内
OK  p1：試算でふだんの道が通れるマスは、地図でも2（待機場所を床にした分だけ増える）
OK  p1：会計の列のマスは、地図では通れない
OK  p1：地図の上でも、ふだんの道で行ける卓は試算の robot の卓と同じ、狭い道でだけ行ける卓は narrow の卓と同じ
OK  p1：どの待機場所の真ん中にも、受け渡し口の前から地図の上で行ける（通れる幅の道）
OK  p5：地図を読み戻すと、マス目の数が同じ
OK  p5：地図を2回作ると同じ文字列（毎回同じ）
OK  p5：障害物までの距離は、計算の値と0.5mm以内
OK  p5：試算でふだんの道が通れるマスは、地図でも2（待機場所を床にした分だけ増える）
OK  p5：会計の列のマスは、地図では通れない
OK  p5：地図の上でも、ふだんの道で行ける卓は試算の robot の卓と同じ、狭い道でだけ行ける卓は narrow の卓と同じ
OK  p5：どの待機場所の真ん中にも、受け渡し口の前から地図の上で行ける（通れる幅の道）
OK  p0：地図を読み戻すと、マス目の数が同じ
OK  p0：地図を2回作ると同じ文字列（毎回同じ）
OK  p0：障害物までの距離は、計算の値と0.5mm以内
OK  p0：試算でふだんの道が通れるマスは、地図でも2（待機場所を床にした分だけ増える）
OK  p0：会計の列のマスは、地図では通れない
OK  p0：地図の上でも、ふだんの道で行ける卓は試算の robot の卓と同じ、狭い道でだけ行ける卓は narrow の卓と同じ
OK  p0：どの待機場所の真ん中にも、受け渡し口の前から地図の上で行ける（通れる幅の道）
OK  会計の列がないと、②は会計に立つ点の近くをロボットが通り、確かめに引っかかる
OK  ボックス席の列は、すき間なく並んだものだけをまとめる

すべて OK
```

### ドリンクバーの案を比べたコード（drink_variants.py）

tools/sim_layouts/ で `python3 drink_variants.py <出力先>` として動かし、今・A・B の図（LOG374_02_drinkbar_options.png のもと）と、卓までの距離の変わり方を出しました。

```python
import sys, copy, json
sys.path.insert(0, '.')
import numpy as np, matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
font_manager.fontManager.addfont('/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc')
plt.rcParams['font.family'] = 'Noto Sans CJK JP'
import famires_v3 as v, famires_gen2 as gen, unity_export as ux
OUT = sys.argv[1]
VARS = [("今", None), ("A 上へ0.5m・左へ0.8m", [16.0, 3.3, 20.0, 4.0]), ("B 上へ0.3m・左へ0.8m", [16.0, 3.1, 20.0, 3.8])]
base = {}
for name, rect in VARS:
    L = gen.p1_efficient_corner()
    if rect:
        for r in L["rooms"]:
            if r["label"] == "ドリンクバー": r["rect"] = rect
    L = v.prepare(L)
    viol = v.check_rules(L)
    ctx = {}
    res, err = v.analyze(L, v.SETTINGS["res"], ctx)
    meta = dict(settings=v.SETTINGS, robot_model=dict(name="BellaBot", **v.ROBOT_MODELS["BellaBot"]))
    U = ux.build(dict(L, robot_keepout=v.checkout_keepout(L)), res, ctx["grid"], ctx["d_norm"], ctx["d_pass"], ctx["sides"], v.standby_slots(L["robot_standby"]), v.FURNITURE_TYPES, {})
    gm = v.Grid(L, v.SETTINGS["res"], standby=[])
    cls = np.where(gm.free(1.0), 2, np.where(gm.free(0.8), 1, 0))
    d = {a["id"]: a.get("dist") for a in res}
    if not rect: base = d
    print(name, "違反", viol, err, "robot卓", sum(a["category"] == "robot" for a in res))
    print("  距離の変わった卓", {k: (base[k], d[k]) for k in d if base.get(k) != d[k]})
    pts = [p for p in U["places"] if p["kind"] in ("DrinkBarStand", "DrinkBarQueue")]
    print("  立つ点・並ぶ点", [(p["kind"][9:], p["point"]) for p in pts])
    fig, ax = plt.subplots(figsize=(9, 6.2), dpi=130)
    summ = v.summarize(L, res)
    v.draw(ax, L, res, summ, [])
    ext = [0, gm.nx * gm.res, 0, gm.ny * gm.res]
    ov = np.zeros(cls.shape + (4,)); ov[cls == 2] = (0.2, 0.6, 1.0, 0.28); ov[cls == 1] = (1.0, 0.6, 0.1, 0.35)
    ax.imshow(ov, origin='lower', extent=ext, zorder=5, interpolation='nearest')
    for p in pts:
        ax.plot(*p["point"], 'o', color='#d33' if 'Queue' in p["kind"] else '#a0a', ms=6, zorder=6)
    ax.set_xlim(12.5, 24.2); ax.set_ylim(-0.2, 7.2); ax.set_title(name + "（青＝ロボットのふだんの道が通れる所／橙＝狭い道だけ／赤紫・赤＝ドリンクバーに立つ・並ぶ人）", fontsize=8)
    for t in ax.texts:
        if not (12.5 <= t.get_position()[0] <= 24.2 and -0.2 <= t.get_position()[1] <= 7.2): t.set_visible(False)
    fig.savefig(f"{OUT}/{name[0]}.png", bbox_inches='tight', facecolor='white'); plt.close(fig)
```

### drink_variants.py の出力

```text
今 違反 [] [] robot卓 42
  距離の変わった卓 {}
  立つ点・並ぶ点 [('tand', [17.3, 3.9]), ('tand', [18.3, 3.9]), ('tand', [19.3, 3.9]), ('tand', [20.3, 3.9]), ('ueue', [18.8, 4.8]), ('ueue', [18.8, 5.3])]
A 上へ0.5m・左へ0.8m 違反 [] [] robot卓 42
  距離の変わった卓 {'T36': (4.62, 4.8), 'T37': (5.72, 5.9), 'T38': (6.82, 7.0), 'T39': (7.92, 7.94), 'T40': (9.02, 8.35), 'T41': (10.12, 9.45)}
  立つ点・並ぶ点 [('tand', [16.5, 4.4]), ('tand', [17.5, 4.4]), ('tand', [18.5, 4.4]), ('tand', [19.5, 4.4]), ('ueue', [18.0, 5.3])]
B 上へ0.3m・左へ0.8m 違反 [] [] robot卓 42
  距離の変わった卓 {'T36': (4.62, 4.91), 'T37': (5.72, 6.01), 'T38': (6.82, 7.11), 'T39': (7.92, 7.82), 'T40': (9.02, 8.23), 'T41': (10.12, 9.33)}
  立つ点・並ぶ点 [('tand', [16.5, 4.2]), ('tand', [17.5, 4.2]), ('tand', [18.5, 4.2]), ('tand', [19.5, 4.2]), ('ueue', [18.0, 5.1])]
```

### commit a33aefd：ドリンクバー・README・試算の条件ファイルの差分

```diff
commit a33aefdcca1dc9c15a0cb6b83c5984bff18d2767
配膳ロボット：③⑦のドリンクバーを動かして右下の行き止まりをなくし、試算 v2.7（料理はロボット優先）を公開（店の形の版 8a5b2f6a016a）


diff --git a/public/sim/serving-robot-params.json b/public/sim/serving-robot-params.json
index 3f3cb45..23f2350 100644
--- a/public/sim/serving-robot-params.json
+++ b/public/sim/serving-robot-params.json
@@ -1,10 +1,10 @@
 {
  "meta": {
   "schema_version": "serving-robot-params/0.1",
-  "model": "v2.4",
+  "model": "v2.7",
   "reps": 40,
   "ng_min": 10,
-  "layout_generation": "2fa10547a2b7",
+  "layout_generation": "8a5b2f6a016a",
   "source": "p2m-site の public/sim/serving-robot.html と同じ試算（haizen_sim.py）と結果（results.json）",
   "roles": {
    "unity_measures": "Unity で測ってサイトに戻すための仮の値。Unity は読まない",
@@ -129,8 +129,12 @@
    "value": 600,
    "role": "site_rule"
   },
+  "robot_first_wait_s": {
+   "value": 120,
+   "role": "site_rule"
+  },
   "model": {
-   "value": "v2.4",
+   "value": "v2.7",
    "role": "site_only"
   }
  },
@@ -226,17 +230,17 @@
     4.67,
     3.81,
     4.0,
-    4.62,
-    5.72,
-    6.82,
-    7.92,
-    9.02,
-    10.12,
+    4.8,
+    5.9,
+    7.0,
+    7.94,
+    8.35,
+    9.45,
     5.43
    ]
   },
   "fam_p5": {
-   "label": "⑦ ③の通路を80cmに",
+   "label": "⑦ ③の通路を95cmに",
    "tables": 42,
    "dist_m": [
     3.0,
@@ -326,12 +330,12 @@
     4.67,
     3.81,
     4.0,
-    4.62,
-    5.72,
-    6.82,
-    7.92,
-    9.02,
-    10.12,
+    4.8,
+    5.9,
+    7.0,
+    7.94,
+    8.35,
+    9.45,
     5.43
    ]
   }
@@ -348,13 +352,13 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.653,
-    "dish_wait_p90": 1.3663,
-    "cold_share": 0.0004,
-    "seat_wait_mean": 2.602,
-    "turn_mean": 9.4605,
-    "walk_km_per_staff": 2.7655,
-    "staff_util": 0.7511,
+    "dish_wait_mean": 0.6466,
+    "dish_wait_p90": 1.3635,
+    "cold_share": 0.0001,
+    "seat_wait_mean": 3.4037,
+    "turn_mean": 9.7179,
+    "walk_km_per_staff": 2.7861,
+    "staff_util": 0.7454,
     "robot_util": 0.0,
     "robot_share": 0.0,
     "robot_bus_share": 0.0,
@@ -362,12 +366,12 @@
     "rescues": 0,
     "pauses": 0,
     "fragile_robot": 0,
-    "groups": 84.2,
-    "entry_wait_mean": 3.1814,
-    "blocked_wait_mean": 2.4548,
-    "guide_wait_mean": 0.3997,
-    "staff_wait_mean": 2.8545,
-    "full_wait_mean": 0.1472
+    "groups": 84.475,
+    "entry_wait_mean": 3.99,
+    "blocked_wait_mean": 2.9486,
+    "guide_wait_mean": 0.4067,
+    "staff_wait_mean": 3.3554,
+    "full_wait_mean": 0.455
    }
   },
   {
@@ -381,26 +385,26 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.5455,
-    "dish_wait_p90": 1.1186,
-    "cold_share": 0.0008,
-    "seat_wait_mean": 1.4919,
-    "turn_mean": 5.3329,
-    "walk_km_per_staff": 2.1959,
-    "staff_util": 0.6855,
-    "robot_util": 0.5883,
-    "robot_share": 0.4671,
+    "dish_wait_mean": 2.0323,
+    "dish_wait_p90": 3.0681,
+    "cold_share": 0.124,
+    "seat_wait_mean": 1.4083,
+    "turn_mean": 4.2104,
+    "walk_km_per_staff": 1.9076,
+    "staff_util": 0.6428,
+    "robot_util": 0.7572,
+    "robot_share": 0.7275,
     "robot_bus_share": 0.0,
     "collects": 0,
-    "rescues": 0.775,
-    "pauses": 23.975,
-    "fragile_robot": 21.7,
-    "groups": 83.8,
-    "entry_wait_mean": 1.9592,
-    "blocked_wait_mean": 1.1414,
-    "guide_wait_mean": 0.2909,
-    "staff_wait_mean": 1.4322,
-    "full_wait_mean": 0.3505
+    "rescues": 0.725,
+    "pauses": 20.5,
+    "fragile_robot": 32.8,
+    "groups": 82.85,
+    "entry_wait_mean": 1.8213,
+    "blocked_wait_mean": 0.9854,
+    "guide_wait_mean": 0.2347,
+    "staff_wait_mean": 1.2201,
+    "full_wait_mean": 0.4229
    }
   },
   {
@@ -414,26 +418,26 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.4568,
-    "dish_wait_p90": 0.8167,
-    "cold_share": 0.0,
-    "seat_wait_mean": 0.6083,
-    "turn_mean": 4.3928,
-    "walk_km_per_staff": 1.872,
-    "staff_util": 0.6436,
-    "robot_util": 0.4764,
-    "robot_share": 0.7257,
+    "dish_wait_mean": 1.0095,
+    "dish_wait_p90": 2.0763,
+    "cold_share": 0.0158,
+    "seat_wait_mean": 0.6758,
+    "turn_mean": 3.7762,
+    "walk_km_per_staff": 1.6174,
+    "staff_util": 0.6257,
+    "robot_util": 0.605,
+    "robot_share": 0.9918,
     "robot_bus_share": 0.0,
     "collects": 0,
-    "rescues": 1.475,
-    "pauses": 41.625,
-    "fragile_robot": 31.9,
-    "groups": 82.1,
-    "entry_wait_mean": 1.0194,
-    "blocked_wait_mean": 0.5116,
-    "guide_wait_mean": 0.2337,
-    "staff_wait_mean": 0.7453,
-    "full_wait_mean": 0.0967
+    "rescues": 1.4,
+    "pauses": 44.425,
+    "fragile_robot": 42.025,
+    "groups": 84.8,
+    "entry_wait_mean": 1.0758,
+    "blocked_wait_mean": 0.5223,
+    "guide_wait_mean": 0.2245,
+    "staff_wait_mean": 0.7468,
+    "full_wait_mean": 0.1535
    }
   },
   {
@@ -447,13 +451,13 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.6572,
-    "dish_wait_p90": 1.3761,
-    "cold_share": 0.0012,
-    "seat_wait_mean": 3.775,
-    "turn_mean": 10.152,
-    "walk_km_per_staff": 2.8551,
-    "staff_util": 0.7522,
+    "dish_wait_mean": 0.6502,
+    "dish_wait_p90": 1.3481,
+    "cold_share": 0.0011,
+    "seat_wait_mean": 4.1988,
+    "turn_mean": 9.9907,
+    "walk_km_per_staff": 2.8371,
+    "staff_util": 0.7444,
     "robot_util": 0.0,
     "robot_share": 0.0,
     "robot_bus_share": 0.0,
@@ -461,12 +465,12 @@
     "rescues": 0,
     "pauses": 0,
     "fragile_robot": 0,
-    "groups": 85.625,
-    "entry_wait_mean": 4.3607,
-    "blocked_wait_mean": 3.568,
-    "guide_wait_mean": 0.4037,
-    "staff_wait_mean": 3.9717,
-    "full_wait_mean": 0.207
+    "groups": 85.325,
+    "entry_wait_mean": 4.7827,
+    "blocked_wait_mean": 3.5477,
+    "guide_wait_mean": 0.402,
+    "staff_wait_mean": 3.9497,
+    "full_wait_mean": 0.651
    }
   },
   {
@@ -480,26 +484,26 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.5299,
-    "dish_wait_p90": 1.0788,
-    "cold_share": 0.0005,
-    "seat_wait_mean": 1.1136,
-    "turn_mean": 5.1263,
-    "walk_km_per_staff": 2.2447,
-    "staff_util": 0.6905,
-    "robot_util": 0.587,
-    "robot_share": 0.4586,
+    "dish_wait_mean": 2.1584,
+    "dish_wait_p90": 3.2312,
+    "cold_share": 0.1651,
+    "seat_wait_mean": 1.6764,
+    "turn_mean": 4.7822,
+    "walk_km_per_staff": 2.0685,
+    "staff_util": 0.6682,
+    "robot_util": 0.7541,
+    "robot_share": 0.7081,
     "robot_bus_share": 0.0,
     "collects": 0,
-    "rescues": 1,
-    "pauses": 24.925,
-    "fragile_robot": 21.15,
-    "groups": 83.525,
-    "entry_wait_mean": 1.572,
-    "blocked_wait_mean": 0.879,
-    "guide_wait_mean": 0.2779,
-    "staff_wait_mean": 1.1569,
-    "full_wait_mean": 0.2346
+    "rescues": 0.8,
+    "pauses": 20,
+    "fragile_robot": 31.9,
+    "groups": 85.875,
+    "entry_wait_mean": 2.142,
+    "blocked_wait_mean": 1.0793,
+    "guide_wait_mean": 0.2813,
+    "staff_wait_mean": 1.3606,
+    "full_wait_mean": 0.5971
    }
   },
   {
@@ -513,26 +517,26 @@
     "fragile_by": "any"
    },
    "site_result": {
-    "dish_wait_mean": 0.481,
-    "dish_wait_p90": 0.8814,
-    "cold_share": 0.0001,
-    "seat_wait_mean": 0.9795,
-    "turn_mean": 4.5091,
-    "walk_km_per_staff": 1.9533,
-    "staff_util": 0.6561,
-    "robot_util": 0.4897,
-    "robot_share": 0.7311,
+    "dish_wait_mean": 1.0105,
+    "dish_wait_p90": 2.0635,
+    "cold_share": 0.014,
+    "seat_wait_mean": 0.743,
+    "turn_mean": 3.845,
+    "walk_km_per_staff": 1.654,
+    "staff_util": 0.6208,
+    "robot_util": 0.6061,
+    "robot_share": 0.9917,
     "robot_bus_share": 0.0,
     "collects": 0,
-    "rescues": 1.425,
-    "pauses": 40.525,
-    "fragile_robot": 31.85,
-    "groups": 82.925,
-    "entry_wait_mean": 1.4157,
-    "blocked_wait_mean": 0.842,
-    "guide_wait_mean": 0.2535,
-    "staff_wait_mean": 1.0954,
-    "full_wait_mean": 0.1375
+    "rescues": 1.575,
+    "pauses": 43.525,
+    "fragile_robot": 42.65,
+    "groups": 83.525,
+    "entry_wait_mean": 1.1434,
+    "blocked_wait_mean": 0.5823,
+    "guide_wait_mean": 0.2194,
+    "staff_wait_mean": 0.8016,
+    "full_wait_mean": 0.1607
    }
   }
  ]
diff --git a/tools/sim_layouts/README.md b/tools/sim_layouts/README.md
index 1aa11e9..93a8385 100644
--- a/tools/sim_layouts/README.md
+++ b/tools/sim_layouts/README.md
@@ -58,6 +58,7 @@ python3 test_famires_v3.py         # 確かめの仕組みそのものを確か
 6. ドリンクバーあり。ロボットの待機・充電の場所は料理の受け渡し口の横（1台あたり0.8m×0.8m）
 7. ⑦は③と同じ卓・同じ席数のまま、列の間の通路だけを95cmに詰めた比較用（はじめは80cm。2026-10-23、Unity でロボットがほとんど動けず、右端の列の横に余りがあったので、島を右へずらして95cmにした。ふだん走らせる幅1.0mより狭いので、細い通路の卓は狭い道で入る）
 8. ②記憶の店は、昔働いていた店の記憶をもとにした店（layout_v11.json）を決まりに合わせた版。トイレとバックヤード（パントリーの出入り口・洗い場・物置）が客席の奥の壁より外へ出っ張り、奥の端はそろう。トイレへはパントリーの壁ぞいの通路から入る
+9. ③⑦のドリンクバーは、右の6人席との間に1.2mの抜け道を空けて置く（2026-10-24。前は間が0.4mで、右下の2人席の前の通路が行き止まりになり、Unity でロボットが正面から出会って詰まった。上へ0.5m・左へ0.8m動かし、ロボットがドリンクバーのまわりを一周できるようにした。下の通路は1.9mで、卓の前にロボットが止まっていても横を抜けられる）
 
 **ロボット**
 - 通れる幅は機種の公表値。初期値は BellaBot の80cm（ファミレスで多く使われ、調べた中で一番厳しい）
diff --git a/tools/sim_layouts/famires_gen2.py b/tools/sim_layouts/famires_gen2.py
index 506f458..e06e6ff 100644
--- a/tools/sim_layouts/famires_gen2.py
+++ b/tools/sim_layouts/famires_gen2.py
@@ -174,7 +174,9 @@ def p1_efficient_corner():
     rooms += [dict(label="待合", rect=[0.0, 0.9, 0.5, 3.3], kind="guest"),
               # 縦のカウンター。客は入口の側（左）に立ち、入口へ向かって並ぶ。下の通路をふさがない（2026-10-23 エイイチと決めた）
               *register([3.3, 0.4, 4.0, 2.9], [4.0, 0.4, 4.9, 2.9]),
-              dict(label="ドリンクバー", rect=[16.8, 2.8, 20.8, 3.5], kind="drink")]
+              # ドリンクバーは上へ0.5m・左へ0.8m。右の6人席との間に1.2mの抜け道を作り、右下の2人席の前の通路を行き止まりにしない
+              # （ロボットが一周できる。下の通路は1.9mで、卓の前にロボットが止まっていても横を抜けられる。2026-10-24 エイイチと決めた）
+              dict(label="ドリンクバー", rect=[16.0, 3.3, 20.0, 4.0], kind="drink")]
     t = strip(0.0, 4.6, "v", 4)                       # 左の壁ぞいのソファ（トイレの手前で止める）
     t += island(2.4, 4.8, "v", 4)                     # 背中合わせのソファの列（間の通路1.2m）
     t += island(6.1, 4.8, "v", 5)
```

### commit a33aefd：試算のページ serving-robot.html の差分（const D の行を除く）

const D の行は、16,425条件の結果が入った約2.2MBの1行なので、差分から外しました。中身は下の merge_v27.py で作っています。

```diff
diff --git a/public/sim/serving-robot.html b/public/sim/serving-robot.html
index c45f387..3495feb 100644
--- a/public/sim/serving-robot.html
+++ b/public/sim/serving-robot.html
@@ -198,7 +198,7 @@ footer { color: var(--ink-3); font-size: 11px; }
     <span class="series">サービスロボット導入シミュレーション #1</span>
     <h1>配膳ロボット、何台入れたら店は変わる？</h1>
     <p class="lead">飲食店のピーク時間を SimPy で再現しました。店のタイプ・ロボットの使い方・運び方を選ぶと、各条件40回ずつ回した平均が表示されます。</p>
-    <span class="tag">数字はすべて仮置き（試作 v2.6）</span>
+    <span class="tag">数字はすべて仮置き（試作 v2.7）</span>
   </header>
 
   <section>
@@ -403,7 +403,7 @@ footer { color: var(--ink-3); font-size: 11px; }
 </div>
 
 <script>
 const ROWS = D.r.map(r => Object.fromEntries(D.k.map((k, i) => [k, r[i]])));
 const STORES = D.stores;
 const STORE_KEYS = ["small", "family", "large", "hotel", "buffet"];
@@ -748,7 +748,7 @@ function renderAssume() {
     ["立ち往生（スタッフが動かす）", `1往復の${Math.round(c.stuck_rate * 100)}%で、スタッフが動かしに行く（${c.rescue_s}秒＋歩く時間）`],
     ["1往復で複数卓を回るとき", `近い卓から順に回り、卓から卓へ${c.hop_m}mの寄り道`],
     ["仕事の優先順", "ロボットの救出 → 配膳・案内・会計・呼び出し → 下げ膳"],
-    ["配膳の分担", "ロボットとスタッフのうち先に空いた方が運ぶ"],
+    ["配膳の分担", `ロボットが配膳するときは、料理はまずロボットを待つ。${Math.round(c.robot_first_wait_s / 60)}分たってもロボットが空かなければ、そこからはロボットとスタッフのうち先に空いた方が運ぶ。割れ物を人だけが運ぶ設定と、ロボットなしはスタッフが運ぶ（v2.7 から。それまでは、はじめから先に空いた方が運んでいた）`],
     ["下げ膳にも使う場合", "スタッフが片付けに向かうとき、空いているロボットがいれば一緒に卓へ向かう。ロボットはスタッフが皿を載せ終えるまで卓で待ち、洗い場へ戻る。いなければ人だけで下げる（v2.4）"],
   ];
   document.getElementById("assume").innerHTML = list.map(([k, v]) => `<li>${k}：${v}</li>`).join("");
```



## 試算 v2.7 の③⑦の計算し直し（simulation/v2_7/）

③⑦の結果の rows_v27_p1p5.json（約1.8MB）は、iCloud の同じフォルダにあります。

### simulation/v2_7/README.md

````markdown
# 配膳ロボットの試算 v2.7（2026-10-23 回す・2026-10-24 公開）

試算のモデルを v2.7 にした版。料理はロボット優先（料理ができたらまずロボットを待ち、2分たってもロボットが空かなければ、そこからはロボットとスタッフのうち先に空いた方が運ぶ。2026-10-23 エイイチと決めた）。

## 公開までの流れ

- 2026-10-23：全16,425条件を v2.7 で回した（Mac と作業用の計算機で分けて回した。run_shard.py・shard_a.json）。結果のページは p2m-site の枝 wip/robot-first-v27 に置き、公開しなかった
- 2026-10-24：③⑦のドリンクバーを動かした（上へ0.5m・左へ0.8m。右の6人席との間に1.2mの抜け道を作り、右下の2人席の前の通路を行き止まりにしない。店の形の版 82023936eb99 → 2fa10547a2b7（地図の書き出しを足しただけ）→ 8a5b2f6a016a）。距離が変わったのは③⑦の6卓だけなので、③⑦の3,066条件だけを回し直し（run_p1p5.py → rows_v27_p1p5.json）、枝のページの③⑦の行と店の情報・平面図を入れ替えて公開した（merge_v27.py）
- 前提の「配膳の分担」を v2.7 の決まりに直した。⑦の名前を「③の通路を95cmに」に直した（haizen_sim.py の LAYOUT_LABELS。80cmのままだった）

## ③⑦の回し直しの差（compare_v27_drinkbar.txt）

- 入店待ちの差の平均 ③ −0.05分・⑦ −0.06分。店が回らない ③ 203→203・⑦ 202→203。全体 2,956 / 16,425 条件

## 作り方

```
python3 run_p1p5.py rows_v27_p1p5.json                       # ③⑦だけ回す（layouts/ は p2m-site の public/sim/layouts/ の写し）
python3 merge_v27.py <枝の v2.7 のページ> rows_v27_p1p5.json <公開中のページ> serving-robot.v27.html compare_v27_drinkbar.txt
python3 ../v2_6/export_params.py rows_v27_p1p5.json serving-robot-params.json
node ../v2_6/test_page_v26.js serving-robot.v27.html        # 全部の組み合わせを描いて例外がないか
```

- `serving-robot.v27.html` は p2m-site の `public/sim/serving-robot.html`、`serving-robot-params.json` は `public/sim/serving-robot-params.json` に入れた（commit a33aefd）
- 公開済みの記事の数字は前の版のまま
````

### simulation/v2_7/run_p1p5.py

```python
"""③⑦（fam_p1・fam_p5）の条件だけを回す。出力は rows の JSON（haizen_sim.run_condition の戻り値の並び。条件の並びは conditions() と同じ）"""
import json, sys, time
from multiprocessing import Pool
import haizen_sim as h
out = sys.argv[1]
conds = [c for c in h.conditions() if c["store"] in ("fam_p1", "fam_p5")]
t0 = time.time()
with Pool(2) as p:
    rows = p.map(h.run_condition, conds, chunksize=4)
json.dump({"common": h.COMMON, "reps": h.REPS, "stores": {k: h.STORES[k] for k in ("fam_p1", "fam_p5")}, "rows": rows}, open(out, "w"), ensure_ascii=False)
print(len(rows), "rows", round(time.time() - t0), "s", flush=True)
```

### simulation/v2_7/merge_v27.py

```python
"""v2.7 の試算ページのデータ（const D）の③⑦（fam_p1・fam_p5）だけを、ドリンクバーを直した店の形で回し直した結果に入れ替える（2026-10-24）。
使い方: python3 merge_v27.py <枝の v2.7 のページ> <③⑦の結果 rows_v27_p1p5.json> <公開中のページ> <出力するページ> <比べた結果.txt>
- ほかの店の行は枝の v2.7 のまま（同じモデル・同じ店の形なので回し直さない）
- 公開中のページの const D の1行・見出しの版・前提の「配膳の分担」だけを入れ替える（費用の欄・LP 用の色は残す）"""
import json, os, re, sys
import haizen_sim as h
br, newp, live, out, cmp_out = sys.argv[1:6]
STO = ("fam_p1", "fam_p5")
getD = lambda s: [l for l in s.split("\n") if l.startswith("const D = ")]
bl = getD(open(br, encoding="utf-8").read()); assert len(bl) == 1
D = json.loads(bl[0][len("const D = "):].rstrip(";"))
N = json.load(open(newp, encoding="utf-8"))
assert N["common"] == D["common"], "モデルの値が枝の v2.7 と違う"
keys = D["k"][:-1]
newrows = []
for r in N["rows"]:
    row = [round(r[k], 3) if i >= 7 else r[k] for i, k in enumerate(keys)]
    row.append(1 if r["staff_wait_mean"] >= h.NG_MIN else 0)
    newrows.append(row)
it = iter(newrows); old = {}
for i, r in enumerate(D["r"]):
    if r[0] in STO:
        n = next(it)
        assert n[:7] == r[:7], ("条件の並びが違う", n[:7], r[:7])
        old[tuple(r[:7])] = r
        D["r"][i] = n
assert next(it, None) is None
# 店の情報と平面図（build_page_v26.py と同じ作り方）
q = lambda a: [round(v, 2) for v in a]
for k in STO:
    s = dict(N["stores"][k]); dl = s.pop("dist_list")
    an = json.load(open(os.path.join(h.LAYOUT_DIR, s["layout"] + ("_efficient_corner" if k == "fam_p1" else "_crammed") + "_analysis.json"), encoding="utf-8"))
    assert dl == [t["dist"] for t in an["tables"]], "試算に使った距離が店の形と違う"
    s["short"] = s["label"]; s["label"] = f"標準ファミレス（{s['short']}）"; s["dist_mean"] = round(sum(dl) / len(dl), 1)
    s = {a: s[a] for a in D["stores"][k]}   # 枝のページと同じ項目だけ
    D["stores"][k] = s
    L = json.load(open(os.path.join(h.LAYOUT_DIR, s["layout"] + ("_efficient_corner" if k == "fam_p1" else "_crammed") + ".json"), encoding="utf-8"))["layout"]
    W, Dd = L["outer"]["w"], L["outer"]["d"]
    D["plans"][k] = {"w": W, "d": Dd, "outline": L.get("outline") or [[0, 0], [W, 0], [W, Dd], [0, Dd]],
                     "rooms": [q(r["rect"]) + [r["kind"], r["label"]] for r in L["rooms"] if r["kind"] != "outside"],
                     "walls": [q(w["rect"]) for w in L["walls"]],
                     "tables": [q([t["x"], t["y"], t["x"] + t["w"], t["y"] + t["h"]]) + [t["seats"]] for t in L["tables"]],
                     "dish": [q(d["rect"]) for d in L["dish_up"]], "standby": q(L["robot_standby"]["rect"]),
                     "ent": [L["entrance"]["side"], L["entrance"]["a0"], L["entrance"]["a1"]]}
gens = {g["generation"] for g in json.load(open(os.path.join(h.LAYOUT_DIR, "summary.json"), encoding="utf-8"))}
for f in os.listdir(h.LAYOUT_DIR):
    if f.endswith(".json") and f != "summary.json":
        gens.add(json.load(open(os.path.join(h.LAYOUT_DIR, f), encoding="utf-8"))["meta"]["generation"])
assert len(gens) == 1, gens
D["layout_generation"] = gens.pop()
# 比べる
lines = [f"v2.7（枝・店の形 82023936eb99）と、③⑦をドリンクバーを直した店の形（{D['layout_generation']}）で回し直したものの比較"]
ki = {k: i for i, k in enumerate(D["k"])}
for k in STO:
    rs = [r for r in D["r"] if r[0] == k]
    for m in ("entry_wait_mean", "staff_wait_mean", "dish_wait_mean", "robot_share"):
        d = [r[ki[m]] - old[tuple(r[:7])][ki[m]] for r in rs]
        lines.append(f"{k} {m}: 差の平均 {sum(d)/len(d):+.3f}  最小 {min(d):+.3f}  最大 {max(d):+.3f}")
    lines.append(f"{k} 店が回らない {sum(old[tuple(r[:7])][-1] for r in rs)} -> {sum(r[-1] for r in rs)}  卓までの平均 {D['stores'][k]['dist_mean']}")
lines.append(f"全体の店が回らない {sum(r[-1] for r in D['r'])} / {len(D['r'])} 条件")
open(cmp_out, "w", encoding="utf-8").write("\n".join(lines) + "\n"); print("\n".join(lines))
# 公開中のページに入れる
s = open(live, encoding="utf-8").read()
ol = getD(s); assert len(ol) == 1
s = s.replace(ol[0], "const D = " + json.dumps(D, ensure_ascii=False, separators=(",", ":")) + ";")
assert len(re.findall(r"試作 v2\.\d+", s)) == 1
s = re.sub(r"試作 v2\.\d+", "試作 v2.7", s)
o = '["配膳の分担", "ロボットとスタッフのうち先に空いた方が運ぶ"],'
assert s.count(o) == 1
s = s.replace(o, '["配膳の分担", `ロボットが配膳するときは、料理はまずロボットを待つ。${Math.round(c.robot_first_wait_s / 60)}分たってもロボットが空かなければ、そこからはロボットとスタッフのうち先に空いた方が運ぶ。割れ物を人だけが運ぶ設定と、ロボットなしはスタッフが運ぶ（v2.7 から。それまでは、はじめから先に空いた方が運んでいた）`],')
open(out, "w", encoding="utf-8").write(s)
print(len(s), "bytes")
```

### simulation/v2_7/compare_v27_drinkbar.txt

```text
v2.7（枝・店の形 82023936eb99）と、③⑦をドリンクバーを直した店の形（8a5b2f6a016a）で回し直したものの比較
fam_p1 entry_wait_mean: 差の平均 -0.048  最小 -5.296  最大 +5.255
fam_p1 staff_wait_mean: 差の平均 +0.005  最小 -1.694  最大 +2.083
fam_p1 dish_wait_mean: 差の平均 -0.003  最小 -0.118  最大 +0.071
fam_p1 robot_share: 差の平均 +0.000  最小 -0.019  最大 +0.022
fam_p1 店が回らない 203 -> 203  卓までの平均 10.6
fam_p5 entry_wait_mean: 差の平均 -0.055  最小 -4.029  最大 +4.419
fam_p5 staff_wait_mean: 差の平均 -0.015  最小 -2.239  最大 +2.024
fam_p5 dish_wait_mean: 差の平均 -0.002  最小 -0.132  最大 +0.085
fam_p5 robot_share: 差の平均 +0.000  最小 -0.023  最大 +0.018
fam_p5 店が回らない 202 -> 203  卓までの平均 10.8
全体の店が回らない 2956 / 16425 条件
```



## Unity の出力の集計（KIOXIA stageB_cs143/）

### stageB_cs143/README.md

```markdown
# 基準データ v1（段階Bの18回・cs:143）

2026-10-24 の別の会話（「研究室の夜間AI稼働」）で決めた「区切り」の印。夜に回す改良（自動の判定・画面なしで回す・ケースの型・次の条件を選ぶ仕組み）は、ここを答え合わせの材料にする。改良を入れたあとの回は、この18回と比べる。

## 何の版か
- Unity: Unity Version Control cs:143（v9：ロボットがサイトの地図の上で自分で道を引く。NavMesh はロボットの道探しに使わない。人は NavMesh のまま）
- 店の形: p2m-site の public/sim/layouts/ 版 8a5b2f6a016a（③⑦のドリンクバーを上へ0.5m・左へ0.8m。地図 unity/{名前}_map.json つき）
- 試算: v2.7（料理はロボット優先・2分で人も運ぶ）。Unity が読む条件は serving-robot-params.json（layout_generation 8a5b2f6a016a）
- 回した日時: 2026-10-24 16:33ごろ〜23:13（Stage B / Run All（18回））。台帳 unity_ledger_v1.csv の unity_cs = cs:143 の18行
- 出力フォルダ: ../20261024_stageB_cs143_{fam_p1|fam_p5}_r{0|1|2}_s{0|1|2}
- 比べる前の版: cs:140 の18回（../20261023_stageB_*）。来る客の乱数の作り方が cs:141 で変わったので、cs:140 とは来る客が違う

## このフォルダのファイル
- b1_collect.py → runs.txt：36回（cs:140 と cs:143）を1回1行にまとめる
- b2_three_runs.py → b2_three_runs_output.txt：③ロボット2台・種0 の3回（v8b・v9 ドリンクバー前・後）
- b3_stageB_compare.py → b3_stageB_compare_output.txt：条件ごとに cs:140 → cs:143 と試算を並べる
- b4_giveups_by_job.py → b4_giveups_by_job_output.txt：逃がし弁を仕事ごとに数える

## 自動の判定を決めるときに使う事実（この18回から）
- 1条件3回（種0・1・2）。来る客は種ごとに台数に関係なく同じ（64・83・102組。平均83組で試算の約84組とほぼ同じ）
- ばらつきが小さいのは、料理の待ちとロボットが運んだ割合。試算との比はどれも同じ向きで2倍未満（料理の待ち ③0台 0.93/0.65・1台 2.41/2.03・2台 1.51/1.01、⑦0台 1.12/0.65・1台 2.53/2.16・2台 1.38/1.01。割合 ③1台 0.72/0.73・2台 0.90/0.99、⑦1台 0.69/0.71・2台 0.95/0.99）
- 入店待ちは種2（102組）で7〜8.5分、種0・1で1.4〜2.0分。平均は種2で決まり、ロボットの台数で変わらない。3回では判断に使えない
- ロボット0台でも、席が空いているのに待った時間が試算より短い（種0・1で0.3〜0.7分、試算 ③3.36・⑦3.95分）。人の動かし方の側のずれ
- 60秒の止まり（3回の合計）：2台 ③90（うちロボットどうし61）・⑦64（35）、1台 ③13・⑦22、0台 0
- 逃がし弁の半分ほどはドリンクバーの客（移した・あきらめた）。⑦0台で cs:140 の69から148に増えた。ドリンクバーを動かして並ぶ点が2つから1つに減ったのが疑い
- 1回の実時間（run_info の wall_seconds）は10.6〜20.9分。18回の通しは、外出でふたを閉じた分を含めて約6時間40分

## この版で分かっている Unity の作りの問題（改良の最初に直す候補）
- 何もしていないロボットが待機場所へ実際には動かない（GoStandby は NavMeshAgent に行き先を渡すだけで、位置を動かしていない。Claude Code の v9 報告の (6)）。受け渡し口の左下で1号機が2号機に7回続けて止められたのは、これの疑い
- 逃がし弁「卓を空けた」が片付けを飛ばしている（ロボット0台で ③61・⑦92回）。スタッフの仕事が試算より軽く見える一因の疑い
- 台帳の pause_rate・stuck_rate が空
- まだ決めていないこと（v9 報告）：(2) 通れないマスから道を引くときの寄せ方（いまは1.0m以内の通れるマスへ寄せる）、(4) 角の箱（corner_stops）を新しい動き方で数えるか（いまは数えない）
```

### stageB_cs143/b1_collect.py

```python
"""段階Bの18回（cs:140 線に縛る版 と cs:143 地図の上で自分で道を引く版）を1回1行にまとめる（2026-10-24）。使い方: python3 b1_collect.py <service_sim/unity> > runs.jsonl"""
import csv,sys,json,os,statistics as st
B=sys.argv[1]
for cs in ('cs140','cs143'):
  for store in ('fam_p1','fam_p5'):
    for rb in (0,1,2):
      for sd in (0,1,2):
        name=f"20261023_stageB_{store}_r{rb}_s{sd}" if cs=='cs140' else f"20261024_stageB_cs143_{store}_r{rb}_s{sd}"
        d=os.path.join(B,name)
        s=json.load(open(d+'/summary.json')); r=s['site_run']; m=s['site_metrics']; ri=json.load(open(d+'/run_info.json'))
        T=list(csv.DictReader(open(d+'/trips.csv'))); S=list(csv.DictReader(open(d+'/stops.csv')))
        L=[x for x in S if float(x['duration_s'])>=59.5]
        print(json.dumps(dict(cs=cs,store=store,rb=rb,sd=sd,left=r['everyone_left'],early=ri.get('stopped_early'),wall_min=round(ri.get('wall_seconds',0)/60,1),groups=m['groups'],entry=m['entry_wait_mean'],staffw=m.get('staff_wait_mean'),dish=m['dish_wait_mean'],share=m.get('robot_share') or 0,
          trips=len(T),timeout=sum(t.get('timeout')=='1' for t in T),stop_trip=st.mean(float(t['stopped_s']) for t in T) if T else 0,
          s60=len(L),s60r=sum(x['cause']=='もう1台のロボット' for x in L),gu=r['gave_up'],wp=r['warped'],tf=r['table_freed']),ensure_ascii=False))
```

### stageB_cs143/runs.txt

```json
{"cs": "cs140", "store": "fam_p1", "rb": 0, "sd": 0, "left": true, "early": false, "wall_min": 11.6, "groups": 81, "entry": 1.9311, "staffw": 0.7289, "dish": 0.9608, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 10, "wp": 11, "tf": 22}
{"cs": "cs140", "store": "fam_p1", "rb": 0, "sd": 1, "left": true, "early": false, "wall_min": 8.5, "groups": 80, "entry": 2.2816, "staffw": 1.0721, "dish": 0.8046, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 9, "wp": 11, "tf": 22}
{"cs": "cs140", "store": "fam_p1", "rb": 0, "sd": 2, "left": true, "early": false, "wall_min": 9.3, "groups": 86, "entry": 3.9575, "staffw": 2.1577, "dish": 0.913, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 7, "wp": 14, "tf": 22}
{"cs": "cs140", "store": "fam_p1", "rb": 1, "sd": 0, "left": true, "early": false, "wall_min": 9.7, "groups": 88, "entry": 3.5292, "staffw": 1.6372, "dish": 3.8528, "share": 0.5688, "trips": 57, "timeout": 1, "stop_trip": 47.78437543859649, "s60": 21, "s60r": 0, "gu": 58, "wp": 43, "tf": 23}
{"cs": "cs140", "store": "fam_p1", "rb": 1, "sd": 1, "left": true, "early": false, "wall_min": 9.0, "groups": 73, "entry": 1.5759, "staffw": 0.3513, "dish": 3.1393, "share": 0.75, "trips": 70, "timeout": 0, "stop_trip": 18.069758571428572, "s60": 5, "s60r": 0, "gu": 37, "wp": 37, "tf": 6}
{"cs": "cs140", "store": "fam_p1", "rb": 1, "sd": 2, "left": true, "early": false, "wall_min": 9.1, "groups": 85, "entry": 5.9371, "staffw": 2.3347, "dish": 3.8241, "share": 0.5935, "trips": 61, "timeout": 1, "stop_trip": 31.854624590163937, "s60": 16, "s60r": 0, "gu": 36, "wp": 36, "tf": 23}
{"cs": "cs140", "store": "fam_p1", "rb": 2, "sd": 0, "left": true, "early": false, "wall_min": 13.1, "groups": 83, "entry": 2.892, "staffw": 1.6328, "dish": 4.2197, "share": 0.7053, "trips": 99, "timeout": 7, "stop_trip": 72.43318787878788, "s60": 56, "s60r": 24, "gu": 100, "wp": 59, "tf": 35}
{"cs": "cs140", "store": "fam_p1", "rb": 2, "sd": 1, "left": true, "early": false, "wall_min": 9.0, "groups": 74, "entry": 2.3919, "staffw": 1.1051, "dish": 4.4348, "share": 0.6448, "trips": 82, "timeout": 10, "stop_trip": 93.59512195121951, "s60": 62, "s60r": 36, "gu": 74, "wp": 46, "tf": 21}
{"cs": "cs140", "store": "fam_p1", "rb": 2, "sd": 2, "left": true, "early": false, "wall_min": 9.9, "groups": 85, "entry": 9.0378, "staffw": 4.8814, "dish": 4.7154, "share": 0.6825, "trips": 91, "timeout": 7, "stop_trip": 90.43791978021979, "s60": 56, "s60r": 34, "gu": 113, "wp": 73, "tf": 33}
{"cs": "cs140", "store": "fam_p5", "rb": 0, "sd": 0, "left": true, "early": false, "wall_min": 8.6, "groups": 85, "entry": 4.8394, "staffw": 2.1071, "dish": 1.1201, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 9, "wp": 23, "tf": 31}
{"cs": "cs140", "store": "fam_p5", "rb": 0, "sd": 1, "left": true, "early": false, "wall_min": 8.1, "groups": 69, "entry": 1.6157, "staffw": 0.4105, "dish": 1.0004, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 10, "wp": 12, "tf": 14}
{"cs": "cs140", "store": "fam_p5", "rb": 0, "sd": 2, "left": true, "early": false, "wall_min": 8.8, "groups": 74, "entry": 1.6289, "staffw": 0.4422, "dish": 1.1089, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 6, "wp": 19, "tf": 24}
{"cs": "cs140", "store": "fam_p5", "rb": 1, "sd": 0, "left": true, "early": false, "wall_min": 9.1, "groups": 86, "entry": 3.0575, "staffw": 1.7612, "dish": 3.829, "share": 0.5899, "trips": 58, "timeout": 1, "stop_trip": 29.35009655172414, "s60": 9, "s60r": 0, "gu": 43, "wp": 49, "tf": 23}
{"cs": "cs140", "store": "fam_p5", "rb": 1, "sd": 1, "left": true, "early": false, "wall_min": 10.6, "groups": 95, "entry": 9.0647, "staffw": 4.7009, "dish": 3.8111, "share": 0.6179, "trips": 71, "timeout": 1, "stop_trip": 24.567047887323945, "s60": 9, "s60r": 0, "gu": 56, "wp": 60, "tf": 35}
{"cs": "cs140", "store": "fam_p5", "rb": 1, "sd": 2, "left": true, "early": false, "wall_min": 10.4, "groups": 99, "entry": 11.4668, "staffw": 3.353, "dish": 3.5781, "share": 0.6585, "trips": 70, "timeout": 0, "stop_trip": 12.828672857142857, "s60": 2, "s60r": 0, "gu": 40, "wp": 56, "tf": 22}
{"cs": "cs140", "store": "fam_p5", "rb": 2, "sd": 0, "left": true, "early": false, "wall_min": 16.1, "groups": 106, "entry": 15.0673, "staffw": 3.9569, "dish": 3.9381, "share": 0.7463, "trips": 116, "timeout": 9, "stop_trip": 68.5575698275862, "s60": 62, "s60r": 54, "gu": 106, "wp": 107, "tf": 45}
{"cs": "cs140", "store": "fam_p5", "rb": 2, "sd": 1, "left": true, "early": false, "wall_min": 9.4, "groups": 71, "entry": 1.7955, "staffw": 0.5522, "dish": 3.0426, "share": 0.7569, "trips": 102, "timeout": 6, "stop_trip": 48.76268039215686, "s60": 32, "s60r": 27, "gu": 57, "wp": 46, "tf": 4}
{"cs": "cs140", "store": "fam_p5", "rb": 2, "sd": 2, "left": true, "early": false, "wall_min": 10.7, "groups": 102, "entry": 12.6712, "staffw": 4.2409, "dish": 3.6097, "share": 0.7953, "trips": 118, "timeout": 6, "stop_trip": 57.16937372881356, "s60": 49, "s60r": 30, "gu": 69, "wp": 84, "tf": 33}
{"cs": "cs143", "store": "fam_p1", "rb": 0, "sd": 0, "left": true, "early": false, "wall_min": 18.2, "groups": 64, "entry": 1.5149, "staffw": 0.3214, "dish": 0.846, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 8, "wp": 6, "tf": 0}
{"cs": "cs143", "store": "fam_p1", "rb": 0, "sd": 1, "left": true, "early": false, "wall_min": 12.8, "groups": 83, "entry": 1.7438, "staffw": 0.5525, "dish": 0.9386, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 7, "wp": 13, "tf": 24}
{"cs": "cs143", "store": "fam_p1", "rb": 0, "sd": 2, "left": true, "early": false, "wall_min": 12.5, "groups": 102, "entry": 7.1696, "staffw": 4.2236, "dish": 1.0143, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 24, "wp": 12, "tf": 37}
{"cs": "cs143", "store": "fam_p1", "rb": 1, "sd": 0, "left": true, "early": false, "wall_min": 15.6, "groups": 64, "entry": 1.4148, "staffw": 0.1777, "dish": 2.2135, "share": 0.8188, "trips": 65, "timeout": 0, "stop_trip": 13.37495076923077, "s60": 5, "s60r": 0, "gu": 42, "wp": 34, "tf": 1}
{"cs": "cs143", "store": "fam_p1", "rb": 1, "sd": 1, "left": true, "early": false, "wall_min": 13.8, "groups": 83, "entry": 1.665, "staffw": 0.4383, "dish": 2.5071, "share": 0.6893, "trips": 64, "timeout": 1, "stop_trip": 19.6842125, "s60": 4, "s60r": 0, "gu": 33, "wp": 31, "tf": 8}
{"cs": "cs143", "store": "fam_p1", "rb": 1, "sd": 2, "left": true, "early": false, "wall_min": 16.5, "groups": 102, "entry": 7.5011, "staffw": 3.4909, "dish": 2.5064, "share": 0.6614, "trips": 75, "timeout": 0, "stop_trip": 14.013461333333334, "s60": 4, "s60r": 0, "gu": 61, "wp": 50, "tf": 28}
{"cs": "cs143", "store": "fam_p1", "rb": 2, "sd": 0, "left": true, "early": false, "wall_min": 11.7, "groups": 64, "entry": 1.5964, "staffw": 0.2997, "dish": 1.1661, "share": 0.975, "trips": 120, "timeout": 2, "stop_trip": 36.49373583333333, "s60": 24, "s60r": 13, "gu": 45, "wp": 73, "tf": 5}
{"cs": "cs143", "store": "fam_p1", "rb": 2, "sd": 1, "left": true, "early": false, "wall_min": 13.5, "groups": 83, "entry": 2.0113, "staffw": 0.7377, "dish": 1.5257, "share": 0.9029, "trips": 124, "timeout": 3, "stop_trip": 33.9975314516129, "s60": 23, "s60r": 17, "gu": 43, "wp": 51, "tf": 16}
{"cs": "cs143", "store": "fam_p1", "rb": 2, "sd": 2, "left": true, "early": false, "wall_min": 20.9, "groups": 102, "entry": 7.372, "staffw": 3.3987, "dish": 1.8354, "share": 0.8189, "trips": 135, "timeout": 2, "stop_trip": 47.5617837037037, "s60": 43, "s60r": 31, "gu": 96, "wp": 64, "tf": 36}
{"cs": "cs143", "store": "fam_p5", "rb": 0, "sd": 0, "left": true, "early": false, "wall_min": 16.1, "groups": 64, "entry": 1.7959, "staffw": 0.6049, "dish": 1.0693, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 30, "wp": 37, "tf": 12}
{"cs": "cs143", "store": "fam_p5", "rb": 0, "sd": 1, "left": true, "early": false, "wall_min": 15.9, "groups": 83, "entry": 1.8685, "staffw": 0.6678, "dish": 1.0547, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 41, "wp": 28, "tf": 25}
{"cs": "cs143", "store": "fam_p5", "rb": 0, "sd": 2, "left": true, "early": false, "wall_min": 16.9, "groups": 102, "entry": 8.0689, "staffw": 4.5439, "dish": 1.2286, "share": 0, "trips": 0, "timeout": 0, "stop_trip": 0, "s60": 0, "s60r": 0, "gu": 58, "wp": 38, "tf": 55}
{"cs": "cs143", "store": "fam_p5", "rb": 1, "sd": 0, "left": true, "early": false, "wall_min": 13.9, "groups": 64, "entry": 1.6539, "staffw": 0.4188, "dish": 2.1487, "share": 0.8125, "trips": 61, "timeout": 0, "stop_trip": 13.805177049180328, "s60": 5, "s60r": 0, "gu": 56, "wp": 67, "tf": 9}
{"cs": "cs143", "store": "fam_p5", "rb": 1, "sd": 1, "left": true, "early": false, "wall_min": 12.0, "groups": 83, "entry": 1.7516, "staffw": 0.5287, "dish": 2.6766, "share": 0.6262, "trips": 61, "timeout": 0, "stop_trip": 24.384544262295083, "s60": 7, "s60r": 0, "gu": 58, "wp": 48, "tf": 19}
{"cs": "cs143", "store": "fam_p5", "rb": 1, "sd": 2, "left": true, "early": false, "wall_min": 18.4, "groups": 102, "entry": 8.4937, "staffw": 4.5939, "dish": 2.758, "share": 0.6299, "trips": 69, "timeout": 0, "stop_trip": 23.47049855072464, "s60": 10, "s60r": 0, "gu": 93, "wp": 81, "tf": 40}
{"cs": "cs143", "store": "fam_p5", "rb": 2, "sd": 0, "left": true, "early": false, "wall_min": 11.8, "groups": 64, "entry": 1.5844, "staffw": 0.305, "dish": 1.0312, "share": 0.975, "trips": 128, "timeout": 2, "stop_trip": 31.6077046875, "s60": 25, "s60r": 18, "gu": 58, "wp": 83, "tf": 4}
{"cs": "cs143", "store": "fam_p5", "rb": 2, "sd": 1, "left": true, "early": false, "wall_min": 11.1, "groups": 83, "entry": 2.0392, "staffw": 0.7209, "dish": 1.5284, "share": 0.9223, "trips": 129, "timeout": 2, "stop_trip": 29.344762015503875, "s60": 13, "s60r": 6, "gu": 75, "wp": 84, "tf": 20}
{"cs": "cs143", "store": "fam_p5", "rb": 2, "sd": 2, "left": true, "early": false, "wall_min": 10.6, "groups": 102, "entry": 7.8111, "staffw": 4.3815, "dish": 1.5885, "share": 0.9449, "trips": 152, "timeout": 2, "stop_trip": 30.68346447368421, "s60": 26, "s60r": 11, "gu": 141, "wp": 108, "tf": 42}
```

### stageB_cs143/b2_three_runs.py

```python
"""③ロボット2台・種0 の3回を並べる（2026-10-24）。v8b（線に縛る・1004）／v9 ドリンクバー前（1304）／v9 ドリンクバー後（1559）。
使い方: python3 b2_three_runs.py <service_sim/unity>"""
import csv, json, os, sys, statistics as st, collections as C
B = sys.argv[1]
RUNS = [("v8b 線に縛る", "20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1004"),
        ("v9 ドリンクバー前", "20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1304"),
        ("v9 ドリンクバー後", "20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1559")]
for label, name in RUNS:
    d = os.path.join(B, name)
    s = json.load(open(d + "/summary.json")); r = s["site_run"]; m = s["site_metrics"]
    ri = json.load(open(d + "/run_info.json"))
    T = list(csv.DictReader(open(d + "/trips.csv"))); S = list(csv.DictReader(open(d + "/stops.csv")))
    G = list(csv.DictReader(open(d + "/give_ups.csv")))
    L = [x for x in S if float(x["duration_s"]) >= 59.5]
    print(f"== {label}  {name}")
    print(f"  path_mode={ri.get('path_mode')} map_generation={ri.get('map_generation')} 書いた時刻={ri.get('written_at')} 実時間={ri.get('wall_seconds', 0)/60:.1f}分")
    print(f"  全員が店を出た={r['everyone_left']} 店の中の時間={r['total_minutes']:.1f}分 組={m['groups']}")
    print(f"  入店待ち={m['entry_wait_mean']:.2f}分 料理の待ち={m['dish_wait_mean']:.2f}分 ロボットが運んだ割合={(m.get('robot_share') or 0):.3f}")
    print(f"  往復={len(T)} 打ち切り={sum(t.get('timeout') == '1' for t in T)} 1往復で止まった秒（平均）={st.mean(float(t['stopped_s']) for t in T):.1f}")
    for c in ("detours", "robot_yields", "detour_extra_m", "replans", "narrow_used", "plan_ms_max"):
        if T and c in T[0]:
            v = [float(t[c]) for t in T if t[c] != ""]
            print(f"  {c}: 平均 {st.mean(v):.2f} 最大 {max(v):.1f}")
    print(f"  止まり={len(S)} 60秒の止まり={len(L)} 原因={C.Counter(x['cause'] for x in L).most_common()}")
    print(f"  60秒の止まりの場所（1m刻み）={C.Counter((round(float(x['x'])), round(float(x['z']))) for x in L).most_common(8)}")
    print(f"  止まりの解け方={C.Counter(x['resolved'] for x in S).most_common()}")
    print(f"  逃がし弁 あきらめた={r['gave_up']} 移した={r['warped']} 卓を空けた={r['table_freed']}")
    print(f"  逃がし弁の内訳={C.Counter((g['job'], g['action']) for g in G).most_common(8)}")
    if label.endswith("後"):
        print("  60秒の止まりの1件ずつ（ロボット・往復・店の中の時刻・x・z・原因・相手・解け方）:")
        for x in L:
            print(f"    {x['robot_id']} {x['trip_id']} {float(x['t_s']):.0f} {float(x['x']):.2f} {float(x['z']):.2f} {x['cause']} {x['cause_detail']} {x['resolved']}")
```

### stageB_cs143/b2_three_runs_output.txt

```text
== v8b 線に縛る  20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1004
  path_mode=None map_generation=None 書いた時刻=2026-10-24 10:18:23 実時間=13.8分
  全員が店を出た=True 店の中の時間=180.4分 組=64
  入店待ち=1.67分 料理の待ち=2.90分 ロボットが運んだ割合=0.644
  往復=72 打ち切り=11 1往復で止まった秒（平均）=109.9
  止まり=490 60秒の止まり=57 原因=[('もう1台のロボット', 51), ('お客さんとすれ違った', 4), ('人が立ち止まっていた', 1), ('スタッフとすれ違った', 1)]
  60秒の止まりの場所（1m刻み）=[((10, 5), 11), ((10, 4), 10), ((17, 2), 9), ((13, 7), 7), ((19, 2), 6), ((13, 8), 5), ((13, 10), 5), ((16, 4), 1)]
  止まりの解け方=[('自分で動き出した', 395), ('動けなくなった', 57), ('経路を引き直した', 38)]
  逃がし弁 あきらめた=61 移した=54 卓を空けた=19
  逃がし弁の内訳=[(('ドリンクバー', '移した'), 39), (('片付け', '卓を空けた'), 18), (('料理を運ぶ', 'あきらめた'), 16), (('ドリンクバー', 'あきらめた'), 13), (('立ち往生を動かす', 'あきらめた'), 12), (('片付け', 'あきらめた'), 11), (('トイレ', '移した'), 5), (('料理を運ぶ', '移した'), 4)]
== v9 ドリンクバー前  20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1304
  path_mode=MapPlanner map_generation=2fa10547a2b7 書いた時刻=2026-10-24 13:17:49 実時間=13.3分
  全員が店を出た=True 店の中の時間=177.3分 組=64
  入店待ち=1.61分 料理の待ち=2.12分 ロボットが運んだ割合=0.762
  往復=92 打ち切り=10 1往復で止まった秒（平均）=83.4
  detours: 平均 1.04 最大 17.0
  robot_yields: 平均 4.54 最大 156.0
  detour_extra_m: 平均 0.47 最大 72.3
  replans: 平均 43.12 最大 635.0
  narrow_used: 平均 0.14 最大 1.0
  plan_ms_max: 平均 22.79 最大 131.5
  止まり=1280 60秒の止まり=59 原因=[('もう1台のロボット', 36), ('人が立ち止まっていた', 17), ('スタッフとすれ違った', 3), ('お客さんとすれ違った', 2), ('椅子・荷物・ベビーカー', 1)]
  60秒の止まりの場所（1m刻み）=[((17, 2), 16), ((18, 2), 12), ((19, 2), 10), ((13, 7), 6), ((20, 2), 5), ((14, 5), 4), ((6, 4), 3), ((10, 4), 1)]
  止まりの解け方=[('自分で動き出した', 953), ('よける場所へ動いた', 209), ('回り込んで動き出した', 59), ('動けなくなった', 59)]
  逃がし弁 あきらめた=71 移した=78 卓を空けた=5
  逃がし弁の内訳=[(('ドリンクバー', '移した'), 54), (('ドリンクバー', 'あきらめた'), 18), (('片付け', 'あきらめた'), 16), (('料理を運ぶ', 'あきらめた'), 14), (('退店', '移した'), 8), (('呼び出し', 'あきらめた'), 7), (('立ち往生を動かす', 'あきらめた'), 7), (('案内', 'あきらめた'), 6)]
== v9 ドリンクバー後  20261024_p1_efficient_corner_fam_p1_s4_r2_a42_s0_1559
  path_mode=MapPlanner map_generation=8a5b2f6a016a 書いた時刻=2026-10-24 16:11:27 実時間=11.5分
  全員が店を出た=True 店の中の時間=172.5分 組=64
  入店待ち=1.47分 料理の待ち=1.17分 ロボットが運んだ割合=0.994
  往復=114 打ち切り=1 1往復で止まった秒（平均）=33.4
  detours: 平均 0.65 最大 7.0
  robot_yields: 平均 1.41 最大 21.0
  detour_extra_m: 平均 0.35 最大 16.8
  replans: 平均 31.40 最大 427.0
  narrow_used: 平均 0.07 最大 1.0
  plan_ms_max: 平均 18.79 最大 58.5
  止まり=2090 60秒の止まり=20 原因=[('もう1台のロボット', 15), ('人が立ち止まっていた', 4), ('スタッフとすれ違った', 1)]
  60秒の止まりの場所（1m刻み）=[((14, 5), 10), ((20, 3), 4), ((11, 4), 3), ((16, 6), 1), ((2, 6), 1), ((15, 5), 1)]
  止まりの解け方=[('自分で動き出した', 1911), ('よける場所へ動いた', 95), ('回り込んで動き出した', 63), ('動けなくなった', 20), ('入口で待った', 1)]
  逃がし弁 あきらめた=31 移した=59 卓を空けた=2
  逃がし弁の内訳=[(('ドリンクバー', '移した'), 51), (('ドリンクバー', 'あきらめた'), 19), (('片付け', 'あきらめた'), 8), (('退店', '移した'), 4), (('案内', 'あきらめた'), 3), (('トイレ', '移した'), 3), (('会計', '卓を空けた'), 2), (('呼び出し', 'あきらめた'), 1)]
  60秒の止まりの1件ずつ（ロボット・往復・店の中の時刻・x・z・原因・相手・解け方）:
    2 9 1020 20.00 2.71 人が立ち止まっていた Staff02 動けなくなった
    2 9 1095 20.00 2.71 人が立ち止まっていた Staff02 動けなくなった
    2 9 1178 20.00 2.71 人が立ち止まっていた Staff02 動けなくなった
    2 9 1261 20.00 2.71 人が立ち止まっていた Staff02 動けなくなった
    1 15 1672 16.04 5.74 もう1台のロボット ServingRobot02 動けなくなった
    2 21 2336 10.65 3.94 もう1台のロボット ServingRobot01 動けなくなった
    1 22 2332 11.11 4.22 もう1台のロボット ServingRobot02 動けなくなった
    1 22 2407 11.11 4.22 もう1台のロボット ServingRobot02 動けなくなった
    1 26 2809 1.92 5.81 スタッフとすれ違った Staff01 動けなくなった
    1 101 7580 15.35 4.91 もう1台のロボット ServingRobot02 動けなくなった
    1 110 8329 14.39 5.26 もう1台のロボット ServingRobot02 動けなくなった
    1 110 8405 14.39 5.26 もう1台のロボット ServingRobot02 動けなくなった
    1 110 8482 14.39 5.26 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9136 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9224 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9300 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9375 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9452 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9528 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
    1 114 9604 14.30 5.35 もう1台のロボット ServingRobot02 動けなくなった
```

### stageB_cs143/b3_stageB_compare.py

```python
"""段階Bの18回を、条件（店・ロボットの台数）ごとに cs:140 と cs:143 で並べ、試算 v2.7 の値も添える（2026-10-24）。
使い方: python3 b3_stageB_compare.py <runs.txt（b1_collect.py の出力）> <serving-robot-params.json>"""
import json, sys, statistics as st, collections as C
R = [json.loads(l) for l in open(sys.argv[1])]
P = json.load(open(sys.argv[2]))
site = {(c["condition"]["store"], c["condition"]["robots"]): c["site_result"] for c in P["unity_conditions"]}
print("試算", P["meta"]["model"], "店の形", P["meta"]["layout_generation"])
print("全員が店を出た（cs:143）", all(r["left"] for r in R if r["cs"] == "cs143"), " 途中で止めた回", [(r["cs"], r["store"], r["rb"], r["sd"]) for r in R if r["early"]])
gs = C.defaultdict(set)
for r in R:
    gs[(r["cs"], r["store"], r["sd"])].add(r["groups"])
print("来た組（種ごと）", dict(sorted((k, sorted(v)) for k, v in gs.items())))
f = lambda L, k: st.mean(x[k] for x in L)
for store in ("fam_p1", "fam_p5"):
    for rb in (0, 1, 2):
        a = [r for r in R if r["cs"] == "cs140" and r["store"] == store and r["rb"] == rb]
        b = [r for r in R if r["cs"] == "cs143" and r["store"] == store and r["rb"] == rb]
        s = site[(store, rb)]
        print(f"\n== {store} ロボット{rb}台（3回平均。cs:140 → cs:143 ／ 試算）")
        print(f"  入店待ち {f(a,'entry'):.2f} → {f(b,'entry'):.2f} ／ {s['entry_wait_mean']:.2f}   種ごと {[round(x['entry'],2) for x in b]}")
        print(f"  席は空いているのに待った {f(a,'staffw'):.2f} → {f(b,'staffw'):.2f} ／ {s['staff_wait_mean']:.2f}   種ごと {[round(x['staffw'],2) for x in b]}")
        print(f"  料理の待ち {f(a,'dish'):.2f} → {f(b,'dish'):.2f} ／ {s['dish_wait_mean']:.2f}")
        print(f"  ロボットが運んだ割合 {f(a,'share'):.3f} → {f(b,'share'):.3f} ／ {s['robot_share']:.3f}")
        print(f"  1往復で止まった秒 {f(a,'stop_trip'):.1f} → {f(b,'stop_trip'):.1f}   往復（3回の合計） {sum(x['trips'] for x in a)} → {sum(x['trips'] for x in b)}   打ち切り {sum(x['timeout'] for x in a)} → {sum(x['timeout'] for x in b)}")
        print(f"  60秒の止まり（3回の合計） {sum(x['s60'] for x in a)} → {sum(x['s60'] for x in b)}   うちロボットどうし {sum(x['s60r'] for x in a)} → {sum(x['s60r'] for x in b)}")
        print(f"  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） {[sum(x[k] for x in a) for k in ('gu','wp','tf')]} → {[sum(x[k] for x in b) for k in ('gu','wp','tf')]}")
        print(f"  実時間（分） {[x['wall_min'] for x in b]}")
```

### stageB_cs143/b3_stageB_compare_output.txt

```text
試算 v2.7 店の形 8a5b2f6a016a
全員が店を出た（cs:143） True  途中で止めた回 []
来た組（種ごと） {('cs140', 'fam_p1', 0): [81, 83, 88], ('cs140', 'fam_p1', 1): [73, 74, 80], ('cs140', 'fam_p1', 2): [85, 86], ('cs140', 'fam_p5', 0): [85, 86, 106], ('cs140', 'fam_p5', 1): [69, 71, 95], ('cs140', 'fam_p5', 2): [74, 99, 102], ('cs143', 'fam_p1', 0): [64], ('cs143', 'fam_p1', 1): [83], ('cs143', 'fam_p1', 2): [102], ('cs143', 'fam_p5', 0): [64], ('cs143', 'fam_p5', 1): [83], ('cs143', 'fam_p5', 2): [102]}

== fam_p1 ロボット0台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 2.72 → 3.48 ／ 3.99   種ごと [1.51, 1.74, 7.17]
  席は空いているのに待った 1.32 → 1.70 ／ 3.36   種ごと [0.32, 0.55, 4.22]
  料理の待ち 0.89 → 0.93 ／ 0.65
  ロボットが運んだ割合 0.000 → 0.000 ／ 0.000
  1往復で止まった秒 0.0 → 0.0   往復（3回の合計） 0 → 0   打ち切り 0 → 0
  60秒の止まり（3回の合計） 0 → 0   うちロボットどうし 0 → 0
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [26, 36, 66] → [39, 31, 61]
  実時間（分） [18.2, 12.8, 12.5]

== fam_p1 ロボット1台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 3.68 → 3.53 ／ 1.82   種ごと [1.41, 1.67, 7.5]
  席は空いているのに待った 1.44 → 1.37 ／ 1.22   種ごと [0.18, 0.44, 3.49]
  料理の待ち 3.61 → 2.41 ／ 2.03
  ロボットが運んだ割合 0.637 → 0.723 ／ 0.728
  1往復で止まった秒 32.6 → 15.7   往復（3回の合計） 188 → 204   打ち切り 2 → 1
  60秒の止まり（3回の合計） 42 → 13   うちロボットどうし 0 → 0
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [131, 116, 52] → [136, 115, 37]
  実時間（分） [15.6, 13.8, 16.5]

== fam_p1 ロボット2台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 4.77 → 3.66 ／ 1.08   種ごと [1.6, 2.01, 7.37]
  席は空いているのに待った 2.54 → 1.48 ／ 0.75   種ごと [0.3, 0.74, 3.4]
  料理の待ち 4.46 → 1.51 ／ 1.01
  ロボットが運んだ割合 0.678 → 0.899 ／ 0.992
  1往復で止まった秒 85.5 → 39.4   往復（3回の合計） 272 → 379   打ち切り 24 → 7
  60秒の止まり（3回の合計） 174 → 90   うちロボットどうし 94 → 61
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [287, 178, 89] → [184, 188, 57]
  実時間（分） [11.7, 13.5, 20.9]

== fam_p5 ロボット0台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 2.69 → 3.91 ／ 4.78   種ごと [1.8, 1.87, 8.07]
  席は空いているのに待った 0.99 → 1.94 ／ 3.95   種ごと [0.6, 0.67, 4.54]
  料理の待ち 1.08 → 1.12 ／ 0.65
  ロボットが運んだ割合 0.000 → 0.000 ／ 0.000
  1往復で止まった秒 0.0 → 0.0   往復（3回の合計） 0 → 0   打ち切り 0 → 0
  60秒の止まり（3回の合計） 0 → 0   うちロボットどうし 0 → 0
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [25, 54, 69] → [129, 103, 92]
  実時間（分） [16.1, 15.9, 16.9]

== fam_p5 ロボット1台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 7.86 → 3.97 ／ 2.14   種ごと [1.65, 1.75, 8.49]
  席は空いているのに待った 3.27 → 1.85 ／ 1.36   種ごと [0.42, 0.53, 4.59]
  料理の待ち 3.74 → 2.53 ／ 2.16
  ロボットが運んだ割合 0.622 → 0.690 ／ 0.708
  1往復で止まった秒 22.2 → 20.6   往復（3回の合計） 199 → 191   打ち切り 2 → 0
  60秒の止まり（3回の合計） 20 → 22   うちロボットどうし 0 → 0
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [139, 165, 80] → [207, 196, 68]
  実時間（分） [13.9, 12.0, 18.4]

== fam_p5 ロボット2台（3回平均。cs:140 → cs:143 ／ 試算）
  入店待ち 9.84 → 3.81 ／ 1.14   種ごと [1.58, 2.04, 7.81]
  席は空いているのに待った 2.92 → 1.80 ／ 0.80   種ごと [0.3, 0.72, 4.38]
  料理の待ち 3.53 → 1.38 ／ 1.01
  ロボットが運んだ割合 0.766 → 0.947 ／ 0.992
  1往復で止まった秒 58.2 → 30.5   往復（3回の合計） 336 → 409   打ち切り 21 → 6
  60秒の止まり（3回の合計） 143 → 64   うちロボットどうし 111 → 35
  逃がし弁（あきらめた／移した／卓を空けた・3回の合計） [232, 237, 82] → [274, 275, 66]
  実時間（分） [11.8, 11.1, 10.6]
```

### stageB_cs143/b4_giveups_by_job.py

```python
"""逃がし弁を仕事ごとに数える（3回の合計。cs:140 と cs:143）（2026-10-24）。使い方: python3 b4_giveups_by_job.py <service_sim/unity>"""
import csv, os, sys, collections as C
B = sys.argv[1]
for store in ("fam_p1", "fam_p5"):
    for rb in (0, 1, 2):
        for cs, pre in (("cs:140", f"20261023_stageB_{store}_r{rb}_s"), ("cs:143", f"20261024_stageB_cs143_{store}_r{rb}_s")):
            c = C.Counter()
            for sd in (0, 1, 2):
                for g in csv.DictReader(open(os.path.join(B, pre + str(sd), "give_ups.csv"))):
                    c[(g["job"], g["action"])] += 1
            drink = sum(v for (j, _), v in c.items() if j == "ドリンクバー")
            print(f"{store} r{rb} {cs} 合計{sum(c.values())} うちドリンクバー{drink} {c.most_common(8)}")
```

### stageB_cs143/b4_giveups_by_job_output.txt

```text
fam_p1 r0 cs:140 合計128 うちドリンクバー56 [(('片付け', '卓を空けた'), 44), (('ドリンクバー', '移した'), 30), (('ドリンクバー', 'あきらめた'), 26), (('会計', '卓を空けた'), 22), (('トイレ', '移した'), 6)]
fam_p1 r0 cs:143 合計131 うちドリンクバー63 [(('片付け', '卓を空けた'), 46), (('ドリンクバー', 'あきらめた'), 39), (('ドリンクバー', '移した'), 24), (('会計', '卓を空けた'), 15), (('トイレ', '移した'), 7)]
fam_p1 r1 cs:140 合計299 うちドリンクバー128 [(('ドリンクバー', '移した'), 87), (('ドリンクバー', 'あきらめた'), 41), (('料理を運ぶ', 'あきらめた'), 39), (('片付け', '卓を空けた'), 30), (('会計', '卓を空けた'), 22), (('片付け', 'あきらめた'), 19), (('案内', 'あきらめた'), 16), (('トイレ', '移した'), 14)]
fam_p1 r1 cs:143 合計288 うちドリンクバー192 [(('ドリンクバー', '移した'), 105), (('ドリンクバー', 'あきらめた'), 87), (('会計', '卓を空けた'), 21), (('料理を運ぶ', 'あきらめた'), 20), (('片付け', 'あきらめた'), 19), (('片付け', '卓を空けた'), 16), (('トイレ', '移した'), 5), (('立ち往生を動かす', 'あきらめた'), 4)]
fam_p1 r2 cs:140 合計554 うちドリンクバー184 [(('ドリンクバー', '移した'), 118), (('料理を運ぶ', 'あきらめた'), 81), (('ドリンクバー', 'あきらめた'), 66), (('片付け', '卓を空けた'), 61), (('片付け', 'あきらめた'), 55), (('案内', 'あきらめた'), 39), (('会計', '卓を空けた'), 28), (('立ち往生を動かす', 'あきらめた'), 21)]
fam_p1 r2 cs:143 合計429 うちドリンクバー237 [(('ドリンクバー', '移した'), 155), (('ドリンクバー', 'あきらめた'), 82), (('片付け', '卓を空けた'), 33), (('片付け', 'あきらめた'), 30), (('料理を運ぶ', 'あきらめた'), 27), (('会計', '卓を空けた'), 24), (('トイレ', '移した'), 15), (('呼び出し', 'あきらめた'), 14)]
fam_p5 r0 cs:140 合計148 うちドリンクバー69 [(('片付け', '卓を空けた'), 55), (('ドリンクバー', '移した'), 48), (('ドリンクバー', 'あきらめた'), 21), (('会計', '卓を空けた'), 14), (('トイレ', '移した'), 4), (('料理を運ぶ', 'あきらめた'), 3), (('退店', '移した'), 2), (('片付け', 'あきらめた'), 1)]
fam_p5 r0 cs:143 合計324 うちドリンクバー148 [(('片付け', '卓を空けた'), 77), (('ドリンクバー', '移した'), 75), (('ドリンクバー', 'あきらめた'), 73), (('料理を運ぶ', 'あきらめた'), 20), (('片付け', 'あきらめた'), 19), (('会計', '卓を空けた'), 15), (('退店', '移した'), 13), (('トイレ', '移した'), 7)]
fam_p5 r1 cs:140 合計384 うちドリンクバー203 [(('ドリンクバー', '移した'), 141), (('ドリンクバー', 'あきらめた'), 62), (('片付け', '卓を空けた'), 56), (('会計', '卓を空けた'), 24), (('料理を運ぶ', 'あきらめた'), 19), (('片付け', 'あきらめた'), 19), (('トイレ', '移した'), 17), (('案内', 'あきらめた'), 17)]
fam_p5 r1 cs:143 合計471 うちドリンクバー235 [(('ドリンクバー', '移した'), 147), (('ドリンクバー', 'あきらめた'), 88), (('片付け', '卓を空けた'), 51), (('片付け', 'あきらめた'), 43), (('料理を運ぶ', 'あきらめた'), 42), (('会計', '卓を空けた'), 17), (('案内', 'あきらめた'), 13), (('退店', '移した'), 13)]
fam_p5 r2 cs:140 合計551 うちドリンクバー269 [(('ドリンクバー', '移した'), 177), (('ドリンクバー', 'あきらめた'), 92), (('片付け', '卓を空けた'), 45), (('片付け', 'あきらめた'), 37), (('会計', '卓を空けた'), 37), (('料理を運ぶ', 'あきらめた'), 30), (('呼び出し', 'あきらめた'), 30), (('退店', '移した'), 20)]
fam_p5 r2 cs:143 合計615 うちドリンクバー322 [(('ドリンクバー', '移した'), 197), (('ドリンクバー', 'あきらめた'), 125), (('片付け', 'あきらめた'), 63), (('片付け', '卓を空けた'), 46), (('退店', '移した'), 33), (('案内', 'あきらめた'), 32), (('料理を運ぶ', 'あきらめた'), 22), (('会計', '卓を空けた'), 20)]
```

### run_all_log.txt（段階B cs:143 の分）

```text
=== 2026-10-24 23:13:42 段階B（Unity cs:143）===
[ServiceSim] 段階B：ぜんぶ終わりました
  回した: 18 / 18 回
  台帳: /Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv
  一覧（出力フォルダ / status / 実時間 / 入店待ち / ロボットが運んだ割合）:
    1. 20261024_stageB_cs143_fam_p1_r2_s0 / completed / 実時間 11.9 分 / 入店待ち 1.5964 分 / ロボットが運んだ割合 0.975
    2. 20261024_stageB_cs143_fam_p5_r2_s0 / completed / 実時間 19.1 分 / 入店待ち 1.5844 分 / ロボットが運んだ割合 0.975
    3. 20261024_stageB_cs143_fam_p1_r0_s0 / completed / 実時間 32.1 分 / 入店待ち 1.5149 分 / ロボットが運んだ割合 0
    4. 20261024_stageB_cs143_fam_p1_r0_s1 / completed / 実時間 24.4 分 / 入店待ち 1.7438 分 / ロボットが運んだ割合 0
    5. 20261024_stageB_cs143_fam_p1_r0_s2 / completed / 実時間 18.1 分 / 入店待ち 7.1696 分 / ロボットが運んだ割合 0
    6. 20261024_stageB_cs143_fam_p1_r1_s0 / completed / 実時間 17.3 分 / 入店待ち 1.4148 分 / ロボットが運んだ割合 0.8188
    7. 20261024_stageB_cs143_fam_p1_r1_s1 / completed / 実時間 28.1 分 / 入店待ち 1.665 分 / ロボットが運んだ割合 0.6893
    8. 20261024_stageB_cs143_fam_p1_r1_s2 / completed / 実時間 17.4 分 / 入店待ち 7.5011 分 / ロボットが運んだ割合 0.6614
    9. 20261024_stageB_cs143_fam_p1_r2_s1 / completed / 実時間 43.6 分 / 入店待ち 2.0113 分 / ロボットが運んだ割合 0.9029
    10. 20261024_stageB_cs143_fam_p1_r2_s2 / completed / 実時間 21.4 分 / 入店待ち 7.372 分 / ロボットが運んだ割合 0.8189
    11. 20261024_stageB_cs143_fam_p5_r0_s0 / completed / 実時間 22.5 分 / 入店待ち 1.7959 分 / ロボットが運んだ割合 0
    12. 20261024_stageB_cs143_fam_p5_r0_s1 / completed / 実時間 15.9 分 / 入店待ち 1.8685 分 / ロボットが運んだ割合 0
    13. 20261024_stageB_cs143_fam_p5_r0_s2 / completed / 実時間 16.9 分 / 入店待ち 8.0689 分 / ロボットが運んだ割合 0
    14. 20261024_stageB_cs143_fam_p5_r1_s0 / completed / 実時間 38.0 分 / 入店待ち 1.6539 分 / ロボットが運んだ割合 0.8125
    15. 20261024_stageB_cs143_fam_p5_r1_s1 / completed / 実時間 12.1 分 / 入店待ち 1.7516 分 / ロボットが運んだ割合 0.6262
    16. 20261024_stageB_cs143_fam_p5_r1_s2 / completed / 実時間 40.0 分 / 入店待ち 8.4937 分 / ロボットが運んだ割合 0.6299
    17. 20261024_stageB_cs143_fam_p5_r2_s1 / completed / 実時間 11.2 分 / 入店待ち 2.0392 分 / ロボットが運んだ割合 0.9223
    18. 20261024_stageB_cs143_fam_p5_r2_s2 / completed / 実時間 10.7 分 / 入店待ち 7.8111 分 / ロボットが運んだ割合 0.9449
```

### unity_ledger_v1.csv（見出しと cs:143 の18行）

```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
2026-10-24T16:44:48,20261024_stageB_cs143_fam_p1_r2_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,174.2833,699.3919,1.5964,0.2997,1.1661,0.0313,0.975,0.6221,0.6511,2.1231,1.0758,0.7468,1.0095,0.9918,0.5676,,1.8264,,120,2,24,3610.899,0,45,73,5,24,4,0.0752,1
2026-10-24T17:03:52,20261024_stageB_cs143_fam_p5_r2_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,0,1,1,1,120,1,174.2858,709.9011,1.5844,0.305,1.0312,0.0375,0.975,0.6431,0.6478,2.1893,1.1434,0.8016,1.0105,0.9917,0.5696,,1.5677,,128,2,25,3449.849,0,58,83,4,25,4,0.0313,1
2026-10-24T17:36:01,20261024_stageB_cs143_fam_p1_r0_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,0,1,1,1,120,1,172.3251,1094.92,1.5149,0.3214,0.846,0,0,0.6674,0,2.8311,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,8,6,0,0,0,0,1
2026-10-24T18:00:23,20261024_stageB_cs143_fam_p1_r0_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,1,1,1,1,120,1,173.3096,765.7284,1.7438,0.5525,0.9386,0,0,0.7728,0,3.1201,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,7,13,24,0,0,0,1
2026-10-24T18:18:27,20261024_stageB_cs143_fam_p1_r0_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,0,42,serve,1,any,2,1,1,1,120,1,197.7529,751.3111,7.1696,4.2236,1.0143,0.0039,0,0.8091,0,3.6539,3.99,3.3554,0.6466,0,0,,0,,0,0,0,0,0,24,12,37,0,0,0,1
2026-10-24T18:35:45,20261024_stageB_cs143_fam_p1_r1_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,0,1,1,1,120,1,172.8132,934.1382,1.4148,0.1777,2.2135,0.2438,0.8188,0.5981,0.7368,2.1527,1.8213,1.2201,2.0323,0.7275,0.5829,,2.9667,,65,0,5,763.0456,0,42,34,1,5,29,0.0337,1
2026-10-24T19:03:53,20261024_stageB_cs143_fam_p1_r1_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,180.7106,826.8843,1.665,0.4383,2.5071,0.2816,0.6893,0.7113,0.7452,2.5239,1.8213,1.2201,2.0323,0.7275,0.5781,,3.2266,,64,1,4,846.7955,0,33,31,8,4,64,0.0351,1
2026-10-24T19:21:17,20261024_stageB_cs143_fam_p1_r1_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,197.4997,990.956,7.5011,3.4909,2.5064,0.3228,0.6614,0.7597,0.7951,2.954,1.8213,1.2201,2.0323,0.7275,0.5801,,3.0639,,75,0,4,914.2462,0,61,50,28,4,86,0.0431,1
2026-10-24T20:04:55,20261024_stageB_cs143_fam_p1_r2_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,175.4362,808.5378,2.0113,0.7377,1.5257,0.1311,0.9029,0.6969,0.6666,2.3056,1.0758,0.7468,1.0095,0.9918,0.5671,,1.5955,,124,3,23,3367.549,0,43,51,16,23,20,0.0692,1
2026-10-24T20:26:21,20261024_stageB_cs143_fam_p1_r2_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,195.7798,1255.075,7.372,3.3987,1.8354,0.185,0.8189,0.8006,0.7573,2.8058,1.0758,0.7468,1.0095,0.9918,0.5667,,1.8337,,135,2,42,4737.136,0,96,64,36,42,46,0.0573,1
2026-10-24T20:48:50,20261024_stageB_cs143_fam_p5_r0_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,175.7102,965.9666,1.7959,0.6049,1.0693,0.025,0,0.6921,0,2.8811,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,30,37,12,0,0,0,1
2026-10-24T21:04:46,20261024_stageB_cs143_fam_p5_r0_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,174.1521,951.7297,1.8685,0.6678,1.0547,0.0194,0,0.8168,0,3.1138,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,41,28,25,0,0,0,1
2026-10-24T21:21:43,20261024_stageB_cs143_fam_p5_r0_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,199.2553,1012.086,8.0689,4.5439,1.2286,0.0197,0,0.8308,0,3.7975,4.7827,3.9497,0.6502,0,0,,0,,0,0,0,0,0,58,38,55,0,0,0,1
2026-10-24T21:59:44,20261024_stageB_cs143_fam_p5_r1_s0,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,0,1,1,1,120,1,176.7278,834.7532,1.6539,0.4188,2.1487,0.1813,0.8125,0.6404,0.7226,2.2656,2.142,1.3606,2.1584,0.7081,0.5822,,2.1804,,61,0,5,666.5468,0,56,67,9,5,30,0.0342,1
2026-10-24T22:11:50,20261024_stageB_cs143_fam_p5_r1_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,1,1,1,1,120,1,178.9682,720.2909,1.7516,0.5287,2.6766,0.4029,0.6262,0.7866,0.7195,2.6362,2.142,1.3606,2.1584,0.7081,0.5782,,3.7388,,61,0,7,1070.893,0,58,48,19,7,77,0.0427,1
2026-10-24T22:51:50,20261024_stageB_cs143_fam_p5_r1_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,1,42,serve,3,any,2,1,1,1,120,1,201.2002,1105.354,8.4937,4.5939,2.758,0.4016,0.6299,0.7848,0.7783,2.9888,2.142,1.3606,2.1584,0.7081,0.5797,,3.7535,,69,0,9,1287.391,0,93,81,40,9,94,0.0354,1
2026-10-24T23:02:59,20261024_stageB_cs143_fam_p5_r2_s1,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,1,1,1,1,120,1,175.5425,663.6535,2.0392,0.7209,1.5284,0.1456,0.9223,0.7671,0.6621,2.3699,1.1434,0.8016,1.0105,0.9917,0.5618,,1.1702,,129,2,13,3226.386,0,75,84,20,13,16,0.0629,1
2026-10-24T23:13:42,20261024_stageB_cs143_fam_p5_r2_s2,completed,cs:143,8a5b2f6a016a,v2.7,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,199.8904,638.7806,7.8111,4.3815,1.5885,0.1142,0.9449,0.7654,0.7277,2.6593,1.1434,0.8016,1.0105,0.9917,0.5712,,2.1535,,152,2,26,3977.503,0,141,108,42,26,14,0.0647,1
```



## ずれの一覧と設計書

### unity/unity_stageC_gaps_v1.md（この記事の時点の全文）

```markdown
# 段階C：サイトの試算と Unity のずれの一覧 v1（2026-10-24）

元のデータ：段階Bの18回（cs:140・店の形 82023936eb99・試算 v2.7）。③⑦ × ロボット0・1・2台 × 種0・1・2。
分析のコードと結果：KIOXIA `r2r2r-data/service_sim/unity/stageC_v1/`（a1〜a4.py・rows.json・carry.json・*_output.txt）。
理由の分け方は設計書 0.5 章：1 数え方の違い／2 サイトにない動き（すれ違い・混雑）／3 Unity の作りの問題。
対処を実際に入れるのは次の版。この一覧では決めるところまで。

## 1. 合っていたもの

- ロボットが1往復で走る距離：Unity 26〜36m、試算の計算式（卓までの距離＋卓から卓へ3m）25〜35m。ほぼ同じ
- 片付けを後回しにする順番（片付けは優先2、案内・配膳・会計は1、立ち往生は0）：同じ
- ロボットなしの料理の待ち：差0.2〜0.4分。ほとんどが下の表の7（スタッフが今いる所から歩く）で説明がつく

## 2. ロボットが運んだ皿の待ちの内訳（3回平均・分）

| 条件 | 受け渡し口で待った | 運ぶ（記録の値） | うち戻るまでを数えた分（表の1） | 卓に着くまで（見積もり） | 試算の式で卓に着くまで | 1往復で止まった秒 | 立ち往生した往復 |
|---|---|---|---|---|---|---|---|
| ③ 1台 | 1.20 | 2.55 | 1.24 | 1.31 | 0.72 | 33秒 | 10% |
| ③ 2台 | 1.11 | 3.04 | 1.04 | 2.01 | 0.58 | 86秒 | 18% |
| ⑦ 1台 | 1.44 | 2.59 | 1.32 | 1.27 | 0.77 | 22秒 | 6% |
| ⑦ 2台 | 0.76 | 2.60 | 1.09 | 1.51 | 0.61 | 58秒 | 11% |

試算の前提：1往復で止まるのは30%の確率で3〜10秒（平均約2秒）、立ち往生は1%。
「卓に着くまで（見積もり）」は、記録の値から、着いたあとの受け渡し25秒と残りの道のり（Unity の速さで）を引いたもの。止まりは全部着く前に起きたとして引いているので、表の1の大きさは小さめに見積もっている。

## 3. ずれの一覧

| # | ずれ | 大きさ | 理由 | 対処（次の版） |
|---|---|---|---|---|
| 1 | ロボットが運んだ皿の「届いた時刻」が、卓に着いた時刻ではなく、往復を終えて受け渡し口に戻った時刻になっている。客が食べ始めるのもその時刻 | 料理の待ち +1.0〜1.3分/皿。食事の始まりが遅れ、卓が空くのも遅れる（入店待ちにも効く） | 3。ロボットは卓に着いた時点で「届いた」と数えている（ServingRobotController の StopsCompleted）が、皿を届いたにするのは往復の終わり（ServiceOps.OnRobotRunDone）だけ | Unity：卓に着いた時点（StopsCompleted が増えたとき）でその卓の皿を届いたにする。試算と同じ（受け渡し25秒の前に届いた） |
| 2 | ロボットが1往復で止まる時間が長い。立ち往生（60秒止まる）も多い | 止まり 22〜86秒/往復（試算 約2秒）。立ち往生 6〜18%の往復（試算 1%） | 1台：2。止まりの理由は人とのすれ違い・人の立ち止まり（客席の通路が4〜6割）。Unity が測るべき値そのもの。2台：止まる時間の5〜6割と、60秒止まりのほとんどが「もう1台のロボット」。試算には台どうしの邪魔がない。ただし実機の配膳ロボットは台どうしで道を調整する仕組みを持つものが多く、Unity の2台が60秒向かい合うのは作りの問題（3）の疑いが強い | 1台の止まりの値：表の1を直して測り直してから、サイトの pause・stuck に入れるかを決める。2台の台どうしの止まり：Unity の次の版で見直す |
| 3 | ロボットが運んだ料理の割合が低い | 62〜80%（試算 72〜99%） | 2の結果。往復が長いので、2分待っても戻らず人が運ぶ皿が増える | 単独では直さない。1・2の結果を見る |
| 4 | ロボットが走っているときの速さ | 毎秒0.57〜0.58m（試算 0.5m） | 1。Unity の最高速度の設定が0.6m。試算の0.5は仮の値 | どちらにそろえるか決める（Unity の速さは測った値ではなく設定） |
| 5 | ロボットなしで、卓が空くまでが短い | 片付け 5.0〜5.5分（試算 9.5〜10.2分）、席が空くのを待った時間 0.5〜0.9分（試算 2.5〜3.6分） | 未確定。仕事の順番は同じ。疑い：逃がし弁「卓を空けた」が1回に14〜31卓あり、片付けを飛ばして卓を空けている（3） | 記録：「卓を空けた」で片付けを省いた卓を、片付けの時間と分けて数える。そのうえで理由を決める |
| 6 | 逃がし弁がまだ働いている | ロボットなしでも あきらめた6〜10・移した11〜23・卓を空けた14〜31。2台で 57〜113・46〜107・4〜45 | 3。段階Aの終わりの条件（ほぼ0）は満たさずに進めた | 次の版の直す対象として残す。どの仕事で出ているかは give_ups.csv |
| 7 | スタッフが運ぶ時間が長い | 0.43〜0.62分（試算 約0.18分） | 2。試算はスタッフが受け渡し口にいる前提、Unity は今いる所から受け渡し口へ歩く | 直さない（サイトにない動き） |
| 8 | スタッフの歩く距離が長い | 1人 2.6〜3.2km（試算 1.6〜2.9km） | 1と2。道のりの測り方の違い、よける・回り道 | 直さない |
| 9 | 入店待ちが回によって大きくぶれる | 同じ条件で1.6〜15.1分 | 回数が足りない | 次の段階Bで乱数の種を増やす |
| 10 | 往復の記録（trips.csv）の皿の欄（dishes・dish_ready_s・dish_arrived_s）が、同じロボットの次の往復の行に書かれている。往復の始まりの時刻と、卓ごとに着いた時刻の欄がない | 記録だけ。動きには効かない | 3（記録） | Unity：記録を直し、始まりの時刻と卓ごとに着いた時刻を足す |

## 4. 決めること（エイイチ）

- 次の版で Unity に入れる直し：1（届いた時刻）・10（記録）を先に入れるか
- 2の2台の台どうしの止まりを、Unity の作りの問題として直すか、サイトにない動きとして残すか
- 4の速さを、試算の0.5にそろえるか、Unity の0.6にそろえるか、そのままにするか

## 5. エイイチの決定（2026-10-24）

- 1（届いた時刻）と10（往復の記録）を次の版で先に入れる。依頼は claude_code_request_v8_dish_arrival.md（v7b の押し直しの直しも、照らし合わせに Unity の版を入れて同じ依頼に入れた。v7b は送らない）。直したら段階Bの18回を回し直す
- 2の2台の台どうしの止まり（60秒向かい合う）は、Unity の作りの問題（3）として扱う。手を付けるのは、v8 で回し直したあと（どちらの直しが効いたかを分けるため）
- 4の速さは今のまま（Unity の最高 0.6m/秒、試算 0.5m/秒）。手を付けない。帳尻合わせで Unity やサイトの値を動かさない。サイトの数値が正
- v8 の確かめの1回（2026-10-24 08:59・③2台・種0）で店が止まった。原因は cs:140 からある打ち切りの戻し方（2台とも受け渡し口の同じ点に置かれて重なり、止まり続けた）。あわせて、同じ種でも動きがずれると来る客が変わる（83組→105組）ことが分かった。エイイチと決めて、打ち切りで重ねない（A）・来客と組の中身を別の乱数にする（B）を v8b として v8 に足した（claude_code_request_v8b_timeout_rng.md）。行き止まりでの台どうしの向かい合いは、回し直したあとに扱うまま
- 2の台どうしの止まりの根本：ロボットが決まった線の上しか走れない作り（routes）。現実の配膳ロボットは地図の上で自分で道を引き、回り込む。次の版で動き方を変える（unity_design_v0.md の 2026-10-24 の項）
- 動き方を変える依頼 v9（claude_code_request_v9_map_planner.md、2026-10-24）：サイトの地図の上でロボットが自分で道を引く。料理を載せている方が先・3秒で回り込む・通路の真ん中寄り（エイイチの決定）。確かめは③2台・種0を1回、v8b の同じ回（1004）と比べる。18回の回し直しはその結果を見てから

## v9 の確かめ B（2026-10-24）
- 3回の比べ（③2台・種0・64組・どれも回り切る）：v8b（1004）／v9・ドリンクバー前（1304、店の形 2fa10547a2b7）／v9・ドリンクバー後（1559、8a5b2f6a016a）
  - 60秒の止まり 57（ロボットどうし51）／59（36）／20（15）。1往復で止まった秒 110／83／33。打ち切り 11／10／1
  - ロボットが運んだ割合 0.64／0.76／0.99。料理の待ち 2.90／2.12／1.17分（試算 v2.7 は1.01）。入店待ち 1.67／1.61／1.47分（試算1.08）
  - 1304 の60秒止まりの43回は右下の行き止まり（エイイチの指摘でドリンクバーを動かし、1559 で0に）
- 残り（段階Bの18回と合わせて扱う）
  - 受け渡し口の左下（x14.3・z5.3）で1号機が2号機に7回続けて止められた（152〜160分）。止まっているロボットが受け渡し口の近くをふさいでいる疑い
  - スタッフが右下の通路の端（x20.0・z2.7、ドリンクバーの右の抜け道の入口）に4分以上立ち止まり、2号機が通れなかった
  - 逃がし弁92回のうち70回がドリンクバーの客（移した51・あきらめた19）。ドリンクバーを動かす前（1304）も72回で、人の動きの側の問題
- 進め方（エイイチ了承）：Check-in → 段階Bの18回を回し直す（⑦は新しい動き方で初めて回す）→ 残りは段階Cで扱う

## 段階Bの18回の回し直し（cs:143・店の形 8a5b2f6a016a・試算 v2.7。2026-10-24 16:33ごろ〜23:13）
集計は KIOXIA service_sim/unity/stageB_cs143/（b1_collect.py → runs.txt。cs:140 の18回も同じ形で並べた）
- 18回とも全員が店を出た。来る客は種ごとに台数に関係なく同じ（種0 64組・種1 83組・種2 102組。平均83組で試算の約84組とほぼ同じ）
- ロボットありの回は大きく良くなった（3回平均。cs:140 → cs:143、試算）
  - 2台：料理の待ち ③4.46→1.51・⑦3.53→1.38（試算1.01）。ロボットが運んだ割合 ③0.68→0.90・⑦0.77→0.95（試算0.99）。60秒止まりのロボットどうし ③94→61・⑦111→35（3回の合計）。打ち切り ③24→7・⑦21→6
  - 1台：料理の待ち ③3.61→2.41・⑦3.74→2.53（試算2.03・2.16）。割合 ③0.64→0.72・⑦0.62→0.69（試算0.73・0.71）
- 残ったずれ
  1. 入店待ちがロボットの台数で変わらない（Unity ③3.48・3.53・3.66分／⑦3.91・3.97・3.81分。試算 ③3.99→1.82→1.08・⑦4.78→2.14→1.14）。種0・1は1.4〜2.0分、種2（102組）は7〜8.5分で、平均は種2で決まる。席が空いているのに待った分（staff_wait）は種0・1で0.2〜0.7分しかなく、ロボット0台でもスタッフが回っている。試算ではロボット0台のスタッフが足りない（③3.36分）。疑い：Unity のスタッフの仕事が試算より軽い（前回の5：片付けが短い）と、逃がし弁「卓を空けた」が片付けを飛ばしている（ロボット0台で ③61・⑦92 回）
  2. ドリンクバーの逃がし弁が増えた：⑦0台で移した・あきらめた 69→148、③2台で 184→237（3回の合計）。ドリンクバーを動かしたとき、並ぶ点が2つから1つに減った（2つめが待機場所に近すぎる）のが疑い。人の動きの側
  3. v9 の確かめで見た受け渡し口の左下の止まり・抜け道の入口でのスタッフの立ち止まりは、18回で数えて確かめる
```

### unity/unity_design_v0.md（この記事で足した行）

```markdown
- 地図の上で自分で道を引く形の決定（2026-10-24 エイイチ）：(1) ロボットどうしは料理を載せている方が先、空で戻る方がよける（同じなら1号機が先）(2) 前がふさがって3秒空かなければ回り込む道を探す (3) 通路の真ん中寄りを走る（壁や卓のそばを道探しで重くする）。サイトは地図（unity/{名前}_map.json：2.5cmのマス目ごとに 2＝ふだんの道・1＝狭い道だけ・0＝通れない、と障害物までの距離。試算と同じ式。待機場所は床）を版 2fa10547a2b7 で公開。店の形の中身は前の版と同じ。行き先まで2だけで行けるなら2だけ、行けなければ1も重くして使う（試算と同じ規則）。前の動き方（routes をたどる・戻りの線・横へどく・角の決まり）は pathMode=SiteRoutes の切り替えで残す。依頼は claude_code_request_v9_map_planner.md。写す元は site_copy_2fa10547a2b7/
- v9 の確かめ B の1回目（2026-10-24 13:06、店の形 2fa10547a2b7）を見たエイイチの指摘：右下の2人席の前の通路が行き止まり（ドリンクバーと6人席の間が0.4m）で詰まる。ドリンクバーを上へ0.5m・左へ0.8m動かし、右に1.2mの抜け道を作って一周できるようにした（A を選択）。店の形の版 8a5b2f6a016a。距離が変わったのは③⑦の6卓だけで、③⑦だけ試算を回し直し、試算 v2.7（料理はロボット優先）を公開した（p2m-site a33aefd）。Unity の Layouts と params は site_copy_8a5b2f6a016a/ から写した。1回目の記録は動かす前の比べる元として残す
- 夜間に回す改良とのつなぎ（2026-10-24 23時。別の会話「研究室の夜間AI稼働」で、区切り → 自動の判定 → 画面なしで回す → ケースの型 → 次の条件を選ぶ仕組み、の順と決めた）：区切りは cs:143 の段階B18回（2026-10-24 16:33ごろ〜23:13）とし、「基準データ v1」として KIOXIA service_sim/unity/stageB_cs143/README.md に版・出力・判定に使う事実・既知の作りの問題をまとめた。自動の判定は、ばらつきの小さい料理の待ちとロボットが運んだ割合で見る（どれも試算と同じ向きで2倍未満）。入店待ちは種3つでは判断に使えない。ロボット0台のずれは人の動かし方の側。改良の最初に直す候補：待機場所へ動かないロボット（GoStandby）・「卓を空けた」が片付けを飛ばす・台帳の pause_rate と stuck_rate が空・ドリンクバーの並ぶ点が1つ。改良を入れたあとの回は v1 と比べる。記事は LOG374
```
