"""Luật 9-ball MVP + EV full-rack — phase B (FullRack design §3 + §4.2). Tầng LUẬT tách khỏi tầng vật lý: `simulate_shot_multi` chỉ trả facts (potted/scratch/first_contact/balls_final), module này chấm điểm theo luật. EV v2 — 25/07/2026 (Production design §2.1, "anti lucky-9"): foul (scratch HOẶC sai first contact) → ev = None (LOẠI khỏi rank — §3.2, không phải EV thấp: cấm gợi ý cú phạm luật) target = "9" vào lỗ sạch → ev = WIN_EV (ăn bi 9 trực tiếp, đáng tin như pot thường) target < 9 vào lỗ sạch → ev = 1 + POS_COEF × Q(next); bi 9 rơi kèm (combo) KHÔNG cộng gì — bonus tự nhiên, không thưởng target KHÔNG vào (kể cả bi 9 rơi) → ev = 0.0 — combo thuần may mắn bị tính như miss, KHÔNG gợi ý Vì sao đổi (data 24/07): bảng EV cũ cho WIN_EV cho MỌI cú có bi 9 rơi hợp lệ → top-1 là WIN ở 13/15 bàn, tức oracle khai thác combo bi 9 "may mắn" trong grid. Sim deterministic thì chắc chắn, bàn thật có noise — đúng bài học b2-lucky 44% ngày 20/07. App coach không khuyên cú ăn may. `win` VẪN là fact từ sim (bi 9 vào lỗ trong cú hợp lệ, kể cả combo) — giữ nguyên để FE hiện badge "⚑ bi 9 vào lỗ" và dừng ván khi xảy ra thật; nó chỉ không còn ảnh hưởng EV nữa. Bi khác (không target, không 9) vào lỗ trong cú hợp lệ: biến mất, không bonus/phạt (§3.4). Chưa xử lý MVP (§3.6): luật chạm băng sau va chạm, ball-in-hand sau foul. """ from __future__ import annotations from .simulate import POS_COEF WIN_EV = 2.0 # §3.3 — đủ lớn hơn max EV pot thường (1 + 0.5×1 = 1.5) def target_ball(balls) -> str: """Bi mục tiêu = bi số NHỎ NHẤT còn trên bàn (§3.1 — BE tự suy, không tin FE). balls = {"cue": xy, "3": xy, ...}.""" nums = [int(bid) for bid in balls if bid != "cue"] if not nums: raise ValueError("Bàn không có bi mục tiêu (cần ≥1 bi 1..9)") return str(min(nums)) def judge_shot(env_h, m, balls, target): """Chấm outcome ``m`` của simulate_shot_multi theo luật 9-ball MVP. Trả dict: foul : bool — scratch hoặc first_contact != target (kể cả không chạm gì) win : bool — bi 9 vào lỗ trong cú hợp lệ (FACT, kể cả combo — KHÔNG còn ảnh hưởng ev từ 25/07, xem docstring module) next : id bi nhỏ nhất CÒN LẠI sau cú | None nếu hết bàn q : Q(cue_final, next sau cú) — chỉ khi pot target sạch và còn next ev : None nếu foul (loại); ngược lại như bảng EV v2 ở docstring module """ potted = m["potted"] foul = m["scratch"] or (m["first_contact"] != target) win = (not foul) and ("9" in potted) remaining = [bid for bid in balls if bid != "cue" and bid not in potted] nxt = str(min(int(bid) for bid in remaining)) if remaining else None q, ev = 0.0, None if foul: pass # loại — caller không đưa vào rank elif target not in potted: # miss hợp lệ — KỂ CẢ khi win=True (bi 9 rơi mà target không vào là # combo thuần may mắn: chấm như miss để không bao giờ gợi ý — §2.1) ev = 0.0 elif target == "9": ev = WIN_EV # ăn bi 9 trực tiếp = thắng đáng tin elif nxt is None: # hết bàn mà không có bi 9 (bàn tuỳ đặt, bi 9 bị tắt): # pot sạch bi cuối là trọn vẹn — không cần position ev = 1.0 else: q = float(env_h._position_q(m["balls_final"]["cue"], m["balls_final"][nxt])) ev = 1.0 + POS_COEF * q # combo 9 rơi kèm: KHÔNG cộng thêm return {"foul": foul, "win": win, "next": nxt, "q": q, "ev": ev}