diff options
Diffstat (limited to 'scripts/analyze-p54.py')
| -rw-r--r-- | scripts/analyze-p54.py | 159 |
1 files changed, 159 insertions, 0 deletions
diff --git a/scripts/analyze-p54.py b/scripts/analyze-p54.py new file mode 100644 index 0000000..2539228 --- /dev/null +++ b/scripts/analyze-p54.py @@ -0,0 +1,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)) |
