File size: 7,292 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
"""Tầng SIM — chỉ nói sự thật vật lý, không luật, không xếp hạng.

24/07/2026 (FullRack phase A): `simulate_shot_multi` nhận dict {id: xy} bất
kỳ, trả FACTS (potted list / scratch / first_contact / balls_final).
31/07/2026 thêm hai facts cho V2 (`bb_events`, `cue_roll_after` — xem
docstring hàm). `simulate_shot` 3-bi ở lại làm wrapper cho `warmup`.

31/07/2026 (việc D): các khoá xếp hạng EV (`rank_key`, `rank_key_factory`,
`rank2_key`, `EV2_MAX`) xoá theo engine oracle/hybrid — chúng sống ở nhánh
`v1-full`. POS_COEF/SCRATCH_EV Ở LẠI: `rules.judge_shot` và reward env vẫn
định nghĩa Q/EV bằng chúng (hồ sơ nghiên cứu, xem BRIEF bối cảnh 5).
"""

from __future__ import annotations

POS_COEF = 0.5          # EV = pot·(1 + POS_COEF·Q) — khớp reward env
SCRATCH_EV = -0.3       # khớp scratch_penalty env


def path_len_after(xy, ts, t1, *, eps=1e-12):
    """Độ dài polyline (mét) của ``xy`` từ thời điểm ``t1`` trở đi — THUẦN.

    ``xy``: mảng ``(n, 2)`` vị trí theo thời gian; ``ts``: ``(n,)`` mốc thời
    gian tương ứng. Nguồn dự kiến là ``ball.history`` event-based của pooltool
    (KHÔNG cần continuize): snapshot tại MỌI event nên mọi cú dội băng và mọi
    chuyển pha sliding→rolling đều là đỉnh polyline. Nghiệm thu 31/07/2026 so
    với continuize ``dt=0.01`` trên 6 cú trộn xoáy/lực: lệch 0.2–8.9 mm —
    nhỏ hơn nhiều bước lưới ứng viên, đủ cho xếp hạng.

    ``eps`` nới mốc so sánh: ``t1`` chép từ ``event.time`` và ``ts`` chép từ
    cùng nguồn, nhưng qua hai đường float khác nhau — so ``>=`` trần trụi có
    thể rớt đúng snapshot va chạm.
    """
    import numpy as np

    xy = np.asarray(xy, dtype=np.float64).reshape(-1, 2)
    m = np.asarray(ts, dtype=np.float64).ravel() >= float(t1) - eps
    if int(m.sum()) < 2:
        return 0.0
    return float(np.linalg.norm(np.diff(xy[m], axis=0), axis=1).sum())


def simulate_shot_multi(env_h, balls, phi, v0, a, b, render=False):
    """1 cú với N bi bất kỳ. ``balls = {"cue": xy, "1": xy, "5": xy, ...}``
    (bắt buộc có "cue"; id bi = chuỗi số như FE/API gửi).

    Chỉ trả FACTS vật lý — không luật, không EV (tầng đó ở phase B):
      system        : pooltool System nếu render=True (vẽ quỹ đạo) | None
      potted        : list id bi VÀO LỖ trong cú này (không tính cue)
      scratch       : bool — bi cái vào lỗ
      first_contact : id bi đầu tiên cue chạm | None nếu không chạm gì
                      (event BALL_BALL đầu tiên có "cue" — luật 9-ball
                      phase B sẽ so với target để bắt foul)
      balls_final   : {id: np.ndarray (2,) | None nếu vào lỗ} — mọi bi,
                      kể cả cue (nút "Đánh cú này" của FE dùng dict này)

    HAI KEY THÊM 31/07/2026 (ZonePlanner V2, BRIEF bối cảnh 7 — MỞ RỘNG
    THUẦN: hai phép gán, không nhánh cũ nào đọc chúng):
      bb_events     : tuple[(t, (id, id))] — MỌI event BALL_BALL theo thứ tự
                      thời gian. Lọc (i)/(iii) của V2 đọc từ đây ("đường bi
                      mục tiêu không chạm bi khác", "bi cái sau va chạm không
                      đụng bi nào — kể cả double-kiss").
      cue_roll_after: float | None — quãng đường bi cái LĂN SAU cú chạm bi
                      đầu tiên (mét, arc length event-polyline, xem
                      ``path_len_after``) | None nếu cue không chạm bi nào.
                      Khoá xếp hạng số 1 của V2 (§8.2 design).

    Trả None nếu pooltool ném exception (giữ hợp đồng cũ).
    """
    import numpy as np
    import pooltool as pt
    import pooltool.constants as ptc

    system = pt.System(
        table=env_h.table,
        balls={bid: pt.Ball.create(bid, xy=tuple(xy))
               for bid, xy in balls.items()},
        cue=pt.Cue(cue_ball_id="cue"),
    )
    system.cue.set_state(V0=v0, phi=phi, a=a, b=b)
    try:
        pt.simulate(system, inplace=True)
    except Exception:
        return None
    if render:
        try:
            pt.continuize(system, dt=0.01, inplace=True)
        except Exception:
            pass  # fallback: vẽ theo history event-based

    def _pk(bid):
        return system.balls[bid].state.s == ptc.pocketed

    def _xy(bid):
        return np.asarray(system.balls[bid].state.rvw[0][:2], dtype=np.float64)

    first_contact = None
    for ev in system.events:
        if ev.event_type == pt.EventType.BALL_BALL and "cue" in ev.ids:
            first_contact = next(i for i in ev.ids if i != "cue")
            break

    bb_events = tuple((float(ev.time), tuple(ev.ids))
                      for ev in system.events
                      if ev.event_type == pt.EventType.BALL_BALL)

    # quãng đường bi cái SAU cú chạm đầu — từ history event-based (có sẵn
    # không cần continuize; extract_trajectories vẫn fallback vào đúng nguồn
    # này). vectorize hỏng thì trả None thay vì làm chết cả sim: hai key mới
    # là mở rộng, không được phép kéo sập hợp đồng cũ.
    cue_roll_after = None
    t1 = next((t for t, ids in bb_events if "cue" in ids), None)
    if t1 is not None:
        try:
            rvw, _ss, ts = system.balls["cue"].history.vectorize()
        except Exception:
            rvw = None
        if rvw is not None and len(rvw) > 1:
            cue_roll_after = path_len_after(rvw[:, 0, :2], ts, t1)

    return {
        "system": system if render else None,
        "potted": [bid for bid in balls if bid != "cue" and _pk(bid)],
        "scratch": _pk("cue"),
        "first_contact": first_contact,
        "balls_final": {bid: (None if _pk(bid) else _xy(bid))
                        for bid in balls},
        "bb_events": bb_events,
        "cue_roll_after": cue_roll_after,
    }


def simulate_shot(env_h, cue_xy, b1_xy, b2_xy, phi, v0, a, b, render=False):
    """1 cú 3 bi (wrapper tương thích v1) — output y hệt bản cũ 23/07,
    thêm 2 key mới vô hại: ``first_contact``, ``balls_final``."""
    balls = {"cue": cue_xy, "1": b1_xy}
    if b2_xy is not None:
        balls["2"] = b2_xy
    m = simulate_shot_multi(env_h, balls, phi, v0, a, b, render=render)
    if m is None:
        return None

    potted = "1" in m["potted"]
    scratch = m["scratch"]
    b2p = "2" in m["potted"]
    bf = m["balls_final"]
    q = 0.0
    if b2_xy is not None and potted and not scratch:
        q = 1.0 if b2p else env_h._position_q(bf["cue"], bf["2"])
    if scratch:
        ev = SCRATCH_EV
    elif potted:
        ev = 1.0 + POS_COEF * q
    else:
        ev = 0.0
    return {
        "system": m["system"],
        "potted": potted, "scratch": scratch, "b2_potted": b2p,
        "q": q, "ev": ev,
        "cue_final": bf["cue"],  # None khi scratch (bi cái pocketed) — như cũ
        "b2_final": bf["2"] if b2_xy is not None else None,  # None khi b2 vào lỗ
        "first_contact": m["first_contact"],
        "balls_final": bf,
    }