#!/usr/bin/env python3 """P54 profession daily-task (visible + completable) analysis. Reads the `LV_P54_SHOT=1` frame sequence + /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 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))