File size: 14,696 Bytes
78738de
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
"""Cửa vào engine app: ``recommend_v2`` + dataclass kết quả + validate.

31/07/2026 (BRIEF việc D): mọi đường cũ — ``recommend()`` 3 bi, oracle grid
search, hybrid net top-K, zone V1 (raster/``dt``/thang nới), fallback "ít
tệ nhất", 2-ply — XOÁ khỏi ``main`` sau khi gate G2 chứng minh V2 không đổi
hành vi của chúng (60/60 ván hybrid k80 khớp từng dòng CSV 28/07). Bản đầy
đủ sống ở nhánh ``v1-full``; mọi đối chứng sau này chạy ở đó.

Thiết kế đường còn lại: ``Documents/PoolCoach_App_ZonePlanner_Design.md``
§8 (poolcoach-docs). KHÔNG print — CLI/BE tự trình bày.
"""

from __future__ import annotations

import time
from dataclasses import dataclass, field
from typing import Any, Optional

from .geometry import feasible_pockets_full, pocket_name
from .rules import judge_shot, target_ball
from .simulate import simulate_shot, simulate_shot_multi
from .trajectory import extract_trajectories


# ------------------------------------------------------------- full rack

@dataclass
class ShotFull:
    """1 cú full-rack đã rank + chấm luật. Hỗ trợ shot["key"] như Shot."""

    rank: int
    pocket: int
    phi: float
    v0: float
    side: float
    vert: float
    target: str                    # bi mục tiêu (bi nhỏ nhất) lúc đánh
    next: Optional[str]            # bi kế tiếp sau cú | None nếu hết bàn
    win: bool                      # bi 9 vào lỗ hợp lệ
    foul: bool                     # luôn False trong shots của V2 (lọc (i))
    scratch: bool
    potted: list = field(default_factory=list)   # id bi vào lỗ trong cú
    first_contact: Optional[str] = None  # bi cue chạm đầu tiên | None
    q: float = 0.0
    ev: float = 0.0
    balls_final: Any = None        # {id: xy | None} — nút "Đánh cú này" FE
    system: Any = None
    trajectories: Optional[dict] = None
    #: Khoá đã dùng để xếp hạng cú này — V2 luôn là ``"roll"``. Giữ dạng cờ
    #: chứ không bỏ hẳn: response tự khai tiêu chí của nó, và bản kế tiếp
    #: (safety/kick) sẽ cần phân biệt.
    rank_by: Optional[str] = None
    #: Zone V2 (31/07/2026, design §8.2): quãng đường bi cái lăn SAU va chạm
    #: (mét, arc length thật — đường có băng dài hơn đường thẳng) — khoá xếp
    #: hạng số 1, FE hiện thay cho EV/Q.
    roll_len: Optional[float] = None
    #: Zone V2: khoảng cách đáp ``d(cue_final, bi kế)`` (mét) — vế phá hoà
    #: ``|d − 1.0|``. None ở cú cuối ván (không có bi kế — "không đo được",
    #: đừng đọc thành 0 mét).
    d_land: Optional[float] = None

    def __getitem__(self, key):
        return getattr(self, key)


@dataclass
class RecommendFullResult:
    engine: str
    target: str = ""
    shots: list = field(default_factory=list)  # [] nếu không có cú hợp lệ ăn bi
    n_results: int = 0
    n_legal_pot: int = 0     # số cú hợp lệ ăn được target (25/07: không còn
                             # tính cú chỉ làm rơi bi 9 — xem §2.1)
    n_win: int = 0           # trong đó số cú thắng (bi 9 vào lỗ)
    elapsed_s: float = 0.0
    pockets: list = field(default_factory=list)  # [(idx, phi, cut, blocked)]
    fallback: Any = None     # ShotFull | None — CHỈ set khi shots rỗng (§2.2)


OVERLAP_EPS = 1e-6       # m — hai bi TỰA vào nhau sau sim cách nhau ĐÚNG 2R;
                         # sai số float một chiều là raise. Nới đúng bằng dung
                         # sai float, không hơn: 1 µm không che nổi cú chồng bi
                         # thật (input rác lệch cỡ mm trở lên).


def validate_full(env_h, balls):
    """Validate bàn N bi (§5): cue + ≥1 bi 1..9, trong bàn, không chồng.
    Message tiếng Việt cùng style validate v1 (BE trả 422).

    BẤT BIẾN: mọi trạng thái ``simulate_shot_multi`` sinh ra PHẢI qua được hàm
    này. Vỡ bất biến đó thì mọi vòng lặp bơm ``balls_final`` ngược vào
    ``recommend_v2`` chết giữa ván — ``gen_demo_boards.py`` và ``autoPlay()``
    của FE đều là vòng lặp đó.

    Biên vị trí đọc từ ``env_h._bounds`` (xem ``envs.position_env.table_bounds``)
    — KHÔNG tính lại ở đây, và KHÔNG phải hằng số riêng của module này.
    """
    import numpy as np

    from poolcoach_rl.envs.position_env import BALL_R

    # Biên vị trí = ENVELOPE VẬT LÝ dẫn từ hình học của chính bàn đang mô
    # phỏng: hộp bao của (mặt bàn ∪ mọi đường tròn lỗ). Bi chưa bị ăn không ra
    # ngoài hộp đó được — muốn ra xa hơn phải đi xuyên một đường tròn lỗ, mà đi
    # qua đường tròn lỗ là bị ăn.
    #
    # Vì sao KHÔNG nới hằng số thêm lần nữa: bản 27/07 nới đúng BALL_R với lập
    # luận "dư ~5× so với mức đo được (28.6 mm vs 5.9 mm)", rồi 28/07 gặp
    # trạng thái vượt 29.7 mm. Chọn headroom theo mẫu ĐÃ THẤY là đúng cái cách
    # làm vừa hụt một lần; mẫu quan sát không bao giờ chứng minh được nó đã
    # chạm đáy. Hộp bao hình học thì chứng minh được, nên không có lần thứ ba.
    #
    # Đánh đổi CHẤP NHẬN CÓ Ý THỨC: hộp bao nhận cả điểm ngoài băng mà xa mọi
    # lỗ (ví dụ x = −0.05, y = 0.60 — bên trái băng, chẳng gần lỗ nào), dù về
    # vật lý bi không tới đó được. Chấp nhận vì (i) đây là hộp CHỨNG MINH ĐƯỢC
    # là chứa trọn mọi vị trí nghỉ hợp lệ, và (ii) biên chặt cho input do NGƯỜI
    # đặt KHÔNG biến mất — nó nằm ở FE: `app/static/app.js` dòng 331-332 kẹp
    # toạ độ kéo-thả về [R, w−R] × [R, l−R]. Đó chính là "tách hai đường": FE
    # chặn chỗ input do người sinh ra, BE chấp nhận đúng tập trạng thái mà sim
    # của CHÍNH NÓ sinh ra được. Kiểm tra không bị xoá đi cho tiện — nó chuyển
    # chỗ. Muốn chặt hơn ở BE thì phải mô hình hoá vùng hàm lỗ.
    if "cue" not in balls:
        raise ValueError("Thiếu bi cái (cue)")
    ids = [bid for bid in balls if bid != "cue"]
    if not ids:
        raise ValueError("Bàn không có bi mục tiêu (cần ≥1 bi 1..9)")
    for bid in ids:
        if bid not in {str(i) for i in range(1, 10)}:
            raise ValueError(f"Id bi không hợp lệ: {bid!r} (chỉ cue và 1..9)")
    w, l = env_h.w, env_h.l
    lo_x, hi_x, lo_y, hi_y = env_h._bounds
    for bid, xy in balls.items():
        # NaN/inf phải bị chặn TRƯỚC, bằng message RIÊNG: mọi so sánh với NaN
        # đều False nên nó rơi thẳng vào nhánh dưới và báo "ngoài bàn" — đúng
        # kiểu message dẫn người debug đi sai hướng.
        if not (np.isfinite(xy[0]) and np.isfinite(xy[1])):
            raise ValueError(f"Vị trí {bid} {tuple(xy)} không phải số hữu hạn "
                             f"(NaN hoặc inf)")
        if not (lo_x <= xy[0] <= hi_x and lo_y <= xy[1] <= hi_y):
            raise ValueError(f"Vị trí {bid} {tuple(xy)} ngoài bàn "
                             f"(bàn {w:.3f} × {l:.3f} m)")
    bids = sorted(balls, key=lambda b: (b != "cue", b))
    for i, a in enumerate(bids):
        for b in bids[i + 1:]:
            if (float(np.linalg.norm(balls[a] - balls[b]))
                    < 2 * BALL_R - OVERLAP_EPS):
                raise ValueError(f"{a}{b} chồng lên nhau (< 2R)")


TRAJ_MIN_DISP = 0.002    # m — bi dịch chuyển ít hơn coi như đứng yên, bỏ khỏi
                         # trajectories (§2e; chỉ áp cho đường full rack)


# ------------------------------------------------ zone V2 (31/07/2026, §8)

ZONE_RANK_ROLL = "roll"  # xếp bằng độ dài đường lăn sau va chạm (V2, §8.2)


def recommend_v2(balls, *, env_h=None, alternatives=3,
                 n_render=1) -> RecommendFullResult:
    """ZonePlanner V2 — đường chọn cú DUY NHẤT của app từ 31/07 (design §8).

    Lịch sử tên hàm: sinh sáng 31/07 làm cửa vào RIÊNG nằm cạnh
    ``recommend_full`` (oracle/hybrid/zone-V1) để gate G2 còn chứng minh được
    "thêm V2 không đổi hành vi engine cũ" — đã chứng minh xong (60/60 ván
    khớp) rồi việc D mới xoá các đường kia; chúng sống ở nhánh ``v1-full``.
    Kết quả khai ``engine="zone"``: với người dùng, ``zone`` là TÊN của đường
    ZonePlanner, còn phiên bản tiêu chí nằm ở ``rank_by`` (``"roll"``).

    Trả ``RecommendFullResult``:
      - ``shots``: top ``1 + alternatives`` từ ``PlanV2Result.pool``, xếp bằng
        ĐÚNG ``rank_key_v2`` mà ``plan_shot_v2`` đã dùng (``sorted`` ổn định ⇒
        phần tử cực tiểu đầu tiên là chính ``plan.shot`` — cùng bất biến V1).
      - hết đường: ``shots = []``, ``fallback = None`` (design §8.3 — không
        thang nới, không cú "ít tệ nhất"; hết đường là thông tin cho người
        tập, message do tầng app soạn).
      - ``n_legal_pot`` = số ứng viên qua ĐỦ 5 lọc cứng trên mọi lỗ đã thử —
        giữ NGHĨA "cú hợp lệ trong không gian tìm kiếm" mà FE đang hiện,
        với "hợp lệ" nay là hợp lệ THEO TIÊU CHÍ V2.
    """
    import numpy as np

    from .zone_v2 import plan_shot_v2, rank_key_v2

    if env_h is None:
        from poolcoach_rl.envs import PositionPlayEnv
        env_h = PositionPlayEnv()

    balls = {bid: np.asarray(xy, dtype=np.float64)
             for bid, xy in balls.items()}
    validate_full(env_h, balls)
    target = target_ball(balls)
    pockets = feasible_pockets_full(env_h, balls, target)

    t0 = time.time()
    plan = plan_shot_v2(env_h, balls, target, pockets=pockets)
    elapsed = time.time() - t0

    ranked = sorted(plan.pool, key=rank_key_v2())[:1 + max(alternatives, 0)]

    shots = []
    for i, c in enumerate(ranked):
        f = c.facts
        shot = ShotFull(rank=i + 1, pocket=f["pocket"], phi=f["phi"],
                        v0=f["v0"], side=f["side"], vert=f["vert"],
                        target=target, next=f["next"], win=f["win"],
                        foul=f["foul"], scratch=f["scratch"],
                        potted=f["potted"], first_contact=f["first_contact"],
                        q=f["q"], ev=f["ev"],
                        balls_final=f["balls_final"],
                        rank_by=ZONE_RANK_ROLL,
                        roll_len=float(c.roll_len),
                        d_land=(None if c.d_land is None
                                else float(c.d_land)))
        if i < n_render:
            rr = simulate_shot_multi(env_h, balls, shot.phi, shot.v0,
                                     shot.side, shot.vert, render=True)
            if rr is not None:
                shot.system = rr["system"]
                shot.trajectories = extract_trajectories(
                    rr["system"], min_disp=TRAJ_MIN_DISP)
                shot.balls_final = rr["balls_final"]
        shots.append(shot)

    return RecommendFullResult(
        engine="zone", target=target, shots=shots,
        n_results=plan.n_cand,
        n_legal_pot=sum(p.n_pass for p in plan.pockets),
        n_win=sum(1 for c in plan.pool if c.facts["win"]),
        elapsed_s=elapsed, pockets=pockets, fallback=None)


def describe_full(env_h, shot) -> str:
    """Diễn giải tiếng người cho cú V2 (nhận ShotFull hoặc dict cùng shape —
    cả hai đều hỗ trợ ``shot["key"]``).

    Từ 31/07 mọi cú tới đây đều đã qua lọc (i) của V2 — không foul, không
    miss — nên các nhánh diễn giải cú phạm luật của bản cũ (fallback "ít tệ
    nhất") đã xoá theo ``pick_fallback``; xem ``v1-full`` nếu cần lại.
    """
    target = shot["target"]
    pocket = pocket_name(env_h, shot["pocket"])
    potted = shot["potted"]

    lines = [f"ĐÁNH BI {target} VÀO LỖ {pocket.upper()}"]

    force = ("nhẹ" if shot["v0"] < 1.5 else
             "vừa" if shot["v0"] < 2.8 else "mạnh")
    lines.append(f"  Hướng    : phi = {shot['phi']:.1f}° (ngắm ghost-ball)")
    lines.append(f"  Lực      : {shot['v0']:.2f} m/s ({force}, thang 0.5-4.0)")
    spin = []
    if shot["vert"] >= 0.15:
        spin.append(f"đánh tới (follow, b=+{shot['vert']:.1f})")
    elif shot["vert"] <= -0.15:
        spin.append(f"kéo về (draw, b={shot['vert']:.1f})")
    if abs(shot["side"]) >= 0.15:
        spin.append(f"xoáy ngang (a={shot['side']:+.1f})")
    lines.append(f"  Xoáy     : {', '.join(spin) if spin else 'không (đánh tâm bi)'}")

    if shot["win"]:
        lines.append("  ⚑ Bi 9 vào lỗ trong cú này — THẮNG nếu đúng "
                     "như mô phỏng")
    extras = [b for b in potted if b != target and b != "9"]
    if extras:
        lines.append(f"  Bonus    : bi {', '.join(extras)} cũng vào lỗ")
    if target in potted and shot["next"] is not None:
        cf = (shot["balls_final"] or {}).get("cue")
        # đọc trường optional không nổ trên dict shape cũ (cùng lý do
        # app/main._field).
        _opt = (shot.get if isinstance(shot, dict)
                else lambda k: getattr(shot, k, None))
        if cf is not None:
            # khoá xếp hạng V2 là ĐƯỜNG LĂN + d_đáp — nói đúng hai số đó,
            # không nói Q/EV (BRIEF việc B.3).
            d_txt = ("" if _opt("d_land") is None
                     else f", cách bi {shot['next']} {_opt('d_land'):.2f} m")
            roll = _opt("roll_len")
            roll_txt = "" if roll is None else f"lăn {roll:.2f} m sau chạm, "
            lines.append(f"  Điều bi  : bi cái {roll_txt}dừng "
                         f"({cf[0]:.2f}, {cf[1]:.2f})"
                         f"{d_txt} — cú tiếp → bi {shot['next']}")
    return "\n".join(lines)


def warmup(env_h) -> float:
    """Chạy 1 cú giả kích JIT Numba (~40s lần đầu). Trả elapsed (giây).
    BE gọi trong background thread lúc startup."""
    import numpy as np

    t0 = time.time()
    simulate_shot(env_h,
                  np.array([env_h.w * 0.5, env_h.l * 0.25]),
                  np.array([env_h.w * 0.5, env_h.l * 0.5]),
                  None, 90.0, 1.5, 0.0, 0.0)
    return time.time() - t0