summaryrefslogtreecommitdiff
path: root/scripts/analyze-p52.py
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-28 23:06:20 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-28 23:06:20 +0800
commit07f2e072a14cc74133b187bb8715758790cdfe67 (patch)
tree715efbba87d3be156e927ed5c3ed857a274ea1ae /scripts/analyze-p52.py
parenta26ba18e9710dcdac57d942d2d7ac1ce1e47ca1a (diff)
downloadliving-village-07f2e072a14cc74133b187bb8715758790cdfe67.tar.gz
p52: 玩家交易通道——买/卖经 Sim.quotePrice→Occupation.biasedQuote,背包侧车/钱财增减 + v3 round-trip + HUD 短反馈(design §2.4-2)
Diffstat (limited to 'scripts/analyze-p52.py')
-rw-r--r--scripts/analyze-p52.py174
1 files changed, 174 insertions, 0 deletions
diff --git a/scripts/analyze-p52.py b/scripts/analyze-p52.py
new file mode 100644
index 0000000..5b9fa86
--- /dev/null
+++ b/scripts/analyze-p52.py
@@ -0,0 +1,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))