"""Gate 1 (ImplPlan Bước 1): bảng EV v2 của `judge_shot` — anti lucky-9. Gọi thẳng `judge_shot` với env stub: tầng luật thuần logic, không đụng vật lý. Điểm mấu chốt phải khoá lại: cú làm rơi bi 9 nhưng KHÔNG ăn được target là combo may mắn → ev = 0.0 (case 4), và combo ăn kèm cũng không được thưởng (case 3) — chỉ bi 9 đúng là target mới đáng WIN_EV (case 1). """ from __future__ import annotations import numpy as np import pytest from poolcoach_rl.recommend.rules import WIN_EV, judge_shot from poolcoach_rl.recommend.simulate import POS_COEF Q_STUB = 0.6 # EnvStub._position_q trả cố định EV_POT = 1.0 + POS_COEF * Q_STUB # 1.3 def make_m(potted, first_contact, balls, scratch=False): """Dựng facts y như `simulate_shot_multi` trả (chỉ phần luật cần đọc).""" return { "potted": list(potted), "scratch": scratch, "first_contact": first_contact, # vị trí thật không quan trọng — _position_q của stub trả hằng số; # chỉ cần đủ key để judge_shot index không KeyError "balls_final": {bid: (None if bid in potted else np.array([0.5, 1.0])) for bid in balls}, } # ---------------------------------------------------------------- case 1-8 def test_case1_target_9_truc_tiep(env_stub): """target = "9" vào lỗ sạch → WIN_EV (cú duy nhất còn được thưởng).""" balls = {"cue": None, "9": None} r = judge_shot(env_stub, make_m(["9"], "9", balls), balls, "9") assert r["foul"] is False assert r["win"] is True assert r["ev"] == pytest.approx(WIN_EV) assert r["next"] is None def test_case2_pot_thuong(env_stub): """pot target sạch, còn bi kế → 1 + POS_COEF × Q.""" balls = {"cue": None, "1": None, "2": None} r = judge_shot(env_stub, make_m(["1"], "1", balls), balls, "1") assert r["foul"] is False assert r["win"] is False assert r["next"] == "2" assert r["q"] == pytest.approx(Q_STUB) assert r["ev"] == pytest.approx(EV_POT) def test_case3_combo_an_kem_khong_duoc_thuong(env_stub): """pot target VÀ bi 9 rơi kèm → vẫn chỉ 1 + POS_COEF × Q, KHÔNG phải WIN_EV. win=True được giữ (FE hiện badge + dừng ván), nhưng EV không đổi. """ balls = {"cue": None, "1": None, "2": None, "9": None} r = judge_shot(env_stub, make_m(["1", "9"], "1", balls), balls, "1") assert r["win"] is True assert r["ev"] == pytest.approx(EV_POT) assert r["ev"] != pytest.approx(WIN_EV) assert r["next"] == "2" def test_case4_lucky_9_bi_cham_nhu_miss(env_stub): """BI 9 RƠI mà target KHÔNG vào → ev = 0.0 (đây là lỗi 13/15 bàn 24/07).""" balls = {"cue": None, "1": None, "9": None} r = judge_shot(env_stub, make_m(["9"], "1", balls), balls, "1") assert r["foul"] is False # chạm đúng bi 1 trước, không scratch assert r["win"] is True # fact: bi 9 đã vào lỗ assert r["ev"] == pytest.approx(0.0) def test_case5_foul_sai_first_contact(env_stub): balls = {"cue": None, "1": None, "2": None} r = judge_shot(env_stub, make_m(["1"], "2", balls), balls, "1") assert r["foul"] is True assert r["ev"] is None def test_case6_foul_khong_cham_gi(env_stub): balls = {"cue": None, "1": None} r = judge_shot(env_stub, make_m([], None, balls), balls, "1") assert r["foul"] is True assert r["ev"] is None def test_case7_foul_scratch_du_pot(env_stub): balls = {"cue": None, "1": None, "2": None} m = make_m(["1"], "1", balls, scratch=True) r = judge_shot(env_stub, m, balls, "1") assert r["foul"] is True assert r["win"] is False # scratch → không tính thắng assert r["ev"] is None def test_case8_pot_bi_cuoi_khong_co_next(env_stub): """Bàn chỉ còn bi 1 (bi 9 bị tắt) → pot sạch = 1.0, không cần position.""" balls = {"cue": None, "1": None} r = judge_shot(env_stub, make_m(["1"], "1", balls), balls, "1") assert r["next"] is None assert r["ev"] == pytest.approx(1.0) # ------------------------------------------------------------------ case 9 def test_case9_filter_legal_loai_lucky_9(env_stub): """Biểu thức filter mới của `recommend_full` loại case 4 khỏi `legal`. Kiểm thẳng biểu thức (core.py) chứ KHÔNG gọi `recommend_full` thật — hàm đó đi xuyên pooltool (`pt.System` nằm NGOÀI try/except của simulate_shot_multi), ngoài phạm vi test contract. """ target = "1" balls = {"cue": None, "1": None, "2": None, "9": None} lucky = make_m(["9"], "1", balls) # case 4: chỉ bi 9 rơi lucky.update(judge_shot(env_stub, lucky, balls, target)) good = make_m(["1"], "1", balls) # case 2: pot target good.update(judge_shot(env_stub, good, balls, target)) results = [lucky, good] legal = [r for r in results if not r["foul"] and target in r["potted"]] assert lucky not in legal # điều cần chứng minh assert legal == [good] # và filter không "rỗng hoá" mọi thứ