#!/usr/bin/env python3 """P52 player-trade analysis (profession slice 2). Reads the `LV_P52_SHOT=1` frames + /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 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))