summaryrefslogtreecommitdiff
path: root/scripts/analyze-p52.py
blob: 5b9fa866bccf6e594361ab4b8883d11cfd17e08f (plain)
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
#!/usr/bin/env python3
"""P52 player-trade analysis (profession slice 2).

Reads the `LV_P52_SHOT=1` frames + <record-dir>/log.txt and writes
docs/evidence/p52-trade.txt with:

  1. buy/sell money delta and backpack delta parsed from the real trade log
     (`Sim.quotePrice` -> `Occupation.biasedQuote` in the shipped game);
  2. Peddler bias direction: buy unit price < sell unit price (买压价/卖抬价);
  3. three real 1280x720 frames (dialog entry / after buy / after sell):
     non-blank, mutually distinct, and the transient HUD feedback band changes
     between the buy and sell frames (player actually sees the result).

Usage:
    python3 scripts/analyze-p52.py <record-dir> <out-txt>

Exit 0 when checks pass, 1 when they fail, 2 on usage/read error.
"""

import re
import sys
from pathlib import Path

import numpy as np
from PIL import Image

TRADE_RE = re.compile(
    r"p52-trade (buy|sell) npc=(.*?) money_before=([\d.]+) money_after=([\d.]+) "
    r"bag_before=\[([^\]]*)\] bag_after=\[([^\]]*)\] status=(.*)")
SHOT_RE = re.compile(r"p52-shot=(\S+) panel=(\S+) status=(.*?) bag=\[([^\]]*)\]")
PRICE_RE = re.compile(r"单价 ([\d.]+)")

FRAMES = ["p52-dialog", "p52-buy", "p52-sell"]
# Transient status pill: DrawTransientStatus anchors at x=16, y=barHeight+accentHeight+8.
PILL_X = (16, 900)
PILL_Y = (44, 80)


def fail(lines, out_txt, message):
    lines.append(f"FAIL: {message}")
    lines.append("")
    lines.append("verdict: FAIL")
    text = "\n".join(lines) + "\n"
    if out_txt is not None:
        out_txt.parent.mkdir(parents=True, exist_ok=True)
        out_txt.write_text(text, encoding="utf-8")
    print(text)
    return 1


def bag(text):
    out = {}
    for part in text.split(";"):
        part = part.strip()
        if not part:
            continue
        key, value = part.split("=")
        out[key] = int(value)
    return out


def load(path):
    return np.asarray(Image.open(path).convert("RGB"), dtype=np.int64)


def main(argv):
    if len(argv) != 3:
        sys.stderr.write(__doc__)
        return 2
    record_dir = Path(argv[1])
    out_txt = Path(argv[2])
    log_path = record_dir / "log.txt"
    if not log_path.exists():
        sys.stderr.write(f"missing {log_path}\n")
        return 2

    lines = ["P52 player-trade analysis (LV_P52_SHOT=1)", ""]
    trades = {}
    shots = {}
    for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
        match = TRADE_RE.search(line)
        if match:
            side = match.group(1)
            trades[side] = {
                "money_before": float(match.group(3)),
                "money_after": float(match.group(4)),
                "bag_before": bag(match.group(5)),
                "bag_after": bag(match.group(6)),
                "status": match.group(7),
            }
        match = SHOT_RE.search(line)
        if match:
            shots[match.group(1)] = {"panel": match.group(2), "status": match.group(3), "bag": bag(match.group(4))}

    if "buy" not in trades or "sell" not in trades:
        return fail(lines, out_txt, "missing buy/sell trade log lines (expected p52-trade buy/sell)")

    prices = {}
    for side in ("buy", "sell"):
        trade = trades[side]
        money_delta = round(trade["money_after"] - trade["money_before"], 2)
        food_delta = trade["bag_after"].get("Food", 0) - trade["bag_before"].get("Food", 0)
        price_match = PRICE_RE.search(trade["status"])
        if not price_match:
            return fail(lines, out_txt, f"{side} status has no 单价: {trade['status']}")
        prices[side] = float(price_match.group(1))
        lines.append(
            f"{side}: money {trade['money_before']:.2f} -> {trade['money_after']:.2f} "
            f"(delta {money_delta:+.2f}), Food {trade['bag_before'].get('Food', 0)} -> "
            f"{trade['bag_after'].get('Food', 0)} (delta {food_delta:+d}), unit {prices[side]:.2f}")

    if not trades["buy"]["status"].startswith("trade ok"):
        return fail(lines, out_txt, "buy did not succeed")
    if not trades["sell"]["status"].startswith("trade ok"):
        return fail(lines, out_txt, "sell did not succeed")
    if not (trades["buy"]["money_after"] < trades["buy"]["money_before"]):
        return fail(lines, out_txt, "buy must decrease player money")
    if not (trades["sell"]["money_after"] > trades["sell"]["money_before"]):
        return fail(lines, out_txt, "sell must increase player money")
    if trades["buy"]["bag_after"].get("Food", 0) - trades["buy"]["bag_before"].get("Food", 0) != 1:
        return fail(lines, out_txt, "buy must add exactly 1 Food to the backpack")
    if trades["sell"]["bag_after"].get("Food", 0) - trades["sell"]["bag_before"].get("Food", 0) != -1:
        return fail(lines, out_txt, "sell must remove exactly 1 Food from the backpack")
    if not (prices["buy"] < prices["sell"]):
        return fail(lines, out_txt, "Peddler buy unit price must be below sell unit price (bias)")

    lines += [
        "",
        f"Peddler bias direction: buy unit {prices['buy']:.2f} < sell unit {prices['sell']:.2f} "
        f"(spread {prices['sell'] - prices['buy']:.2f})",
        "",
    ]

    images = {}
    for name in FRAMES:
        path = record_dir / f"{name}.png"
        if not path.exists():
            return fail(lines, out_txt, f"missing frame {path}")
        img = load(path)
        images[name] = img
        std = float(img.std())
        lines.append(f"frame {name}: {img.shape[1]}x{img.shape[0]} std={std:.1f} panel={shots.get(name, {}).get('panel', '?')}")
        if std < 5.0:
            return fail(lines, out_txt, f"frame {name} looks blank (std {std:.1f})")

    band = (slice(PILL_Y[0], PILL_Y[1]), slice(PILL_X[0], PILL_X[1]))
    buy_pill = images["p52-buy"][band]
    sell_pill = images["p52-sell"][band]
    pill_delta = float(np.abs(buy_pill - sell_pill).mean())
    lines.append(f"transient HUD band buy-vs-sell mean abs delta = {pill_delta:.2f}")
    if pill_delta < 1.0:
        return fail(lines, out_txt, "transient HUD feedback band did not change between buy and sell")

    dialog_vs_buy = float(np.abs(images["p52-dialog"] - images["p52-buy"]).mean())
    lines.append(f"dialog-vs-buy full-frame mean abs delta = {dialog_vs_buy:.2f}")
    if dialog_vs_buy < 1.0:
        return fail(lines, out_txt, "dialog entry frame does not differ from the trade-result frame")

    expected = {"p52-dialog": "dialogue", "p52-buy": "world", "p52-sell": "world"}
    for name, panel in expected.items():
        seen = shots.get(name, {}).get("panel")
        if seen != panel:
            return fail(lines, out_txt, f"{name} expected panel {panel}, got {seen}")

    lines.append("")
    lines.append("verdict: PASS")
    out_txt.parent.mkdir(parents=True, exist_ok=True)
    out_txt.write_text("\n".join(lines) + "\n", encoding="utf-8")
    print("\n".join(lines))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))