summaryrefslogtreecommitdiff
path: root/scripts/analyze-p54.py
blob: 253922812359621cc517f737627de1f1d797e5a1 (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
#!/usr/bin/env python3
"""P54 profession daily-task (visible + completable) analysis.

Reads the `LV_P54_SHOT=1` frame sequence + <record-dir>/log.txt and writes
docs/evidence/p54-daily-task.txt with per-frame measured behaviour:

  1. sequence from the real game log: offered -> active -> (arrive) ->
     dialogue -> buy -> sell -> done, with the real task template/state;
  2. the daily task is Offered at first, becomes Active only after the accept
     command, and reaches Done only after a *real* signal (here: a player
     dialogue) — never by calendar/frame counting (still Active after the
     environment-arrival frame);
  3. the task panel is a real overlay (differs from the world frames) and the
     buy/sell frames show the real backpack delta (Food 12 -> 13 -> 12).

Usage:
    python3 scripts/analyze-p54.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

SHOT_RE = re.compile(
    r"p54-shot=(\S+) occupation=(\S+) template=(\S+) state=(\S+) panel=(\S+) status=(.*?) bag=\[([^\]]*)\]"
)
FRAMES = [
    "p54-task-offered",
    "p54-task-active",
    "p54-arrive",
    "p54-dialogue",
    "p54-buy",
    "p54-sell",
    "p54-task-done",
]
PANEL_DIFF_MIN = 1.0


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 load(path):
    return np.asarray(Image.open(path).convert("RGB"), dtype=np.float64)


def bag_food(bag):
    for part in bag.split(";"):
        if part.startswith("Food="):
            return int(part.split("=")[1])
    return 0


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 = ["P54 profession daily-task analysis (LV_P54_SHOT=1)", ""]
    frames = {}
    for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
        match = SHOT_RE.search(line)
        if match:
            frames[match.group(1)] = {
                "occupation": match.group(2),
                "template": match.group(3),
                "state": match.group(4),
                "panel": match.group(5),
                "status": match.group(6).strip(),
                "bag": match.group(7),
            }

    for name in FRAMES:
        if name not in frames:
            return fail(lines, out_txt, f"missing log frame {name}")
        record = frames[name]
        lines.append(
            f"{name}: template={record['template']} state={record['state']} "
            f"panel={record['panel']} status={record['status']} bag=[{record['bag']}]"
        )

    lines.append("")
    if frames["p54-task-offered"]["state"] != "offered":
        return fail(lines, out_txt, "the daily task must start Offered (visible in the task panel)")
    if frames["p54-task-offered"]["panel"] != "task":
        return fail(lines, out_txt, "the offered frame must be the task panel")
    if frames["p54-task-active"]["state"] != "active":
        return fail(lines, out_txt, "accepting must move the task Offered -> Active")
    if "已接受任务" not in frames["p54-task-active"]["status"]:
        return fail(lines, out_txt, "accepting must surface a short Chinese feedback")
    if frames["p54-arrive"]["state"] == "done":
        return fail(lines, out_txt, "a non-matching environment signal must not complete the task")
    if frames["p54-dialogue"]["state"] != "done":
        return fail(lines, out_txt, "a real player dialogue must complete this dialogue task")
    if "任务完成" not in frames["p54-dialogue"]["status"]:
        return fail(lines, out_txt, "completion must surface a short Chinese 任务完成 feedback")
    if frames["p54-task-done"]["state"] != "done":
        return fail(lines, out_txt, "the task must stay Done in the panel")
    if (
        frames["p54-task-offered"]["state"] != "offered"
        or frames["p54-task-active"]["state"] != "active"
        or frames["p54-dialogue"]["state"] != "done"
    ):
        return fail(lines, out_txt, "task states must progress offered -> active -> done")

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

    panel_delta = float(np.abs(images["p54-task-offered"] - images["p54-dialogue"]).mean())
    food_after_buy = bag_food(frames["p54-buy"]["bag"])
    food_after_sell = bag_food(frames["p54-sell"]["bag"])
    lines += [
        "",
        f"task panel vs world delta = {panel_delta:.2f} (>= {PANEL_DIFF_MIN})",
        f"backpack Food after buy = {food_after_buy}, after sell = {food_after_sell}",
        "",
    ]
    if panel_delta < PANEL_DIFF_MIN:
        return fail(lines, out_txt, "task panel frame does not differ from the world frame")
    if food_after_buy != food_after_sell + 1 or food_after_sell <= 0:
        return fail(lines, out_txt, "buyer backpack must rise then fall back across the real buy/sell")

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


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