summaryrefslogtreecommitdiff
path: root/scripts/analyze-p54.py
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 23:55:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 23:55:20 +0800
commit3669d9710e3783c325385a5648766b798d20ec77 (patch)
tree5fc618616b4a2eab252ea3bb1c32dd5b1f9ddc36 /scripts/analyze-p54.py
parent6ea696b31ea342e43b61cf16aa1b3378a2bc68a3 (diff)
downloadliving-village-3669d9710e3783c325385a5648766b798d20ec77.tar.gz
p54: 当日任务玩家可见与可完成——Kernel 状态机接入真实事件/环境信号(接受/对话/交易/到达/夜水边),HUD 短中文反馈 + 任务面板接受提示 + v3 任务态往返与 save(None) 兼容 + LV_P54_SHOT 证据
Diffstat (limited to 'scripts/analyze-p54.py')
-rw-r--r--scripts/analyze-p54.py159
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))