From 07f2e072a14cc74133b187bb8715758790cdfe67 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Mon, 28 Sep 2026 23:06:20 +0800 Subject: p52: 玩家交易通道——买/卖经 Sim.quotePrice→Occupation.biasedQuote,背包侧车/钱财增减 + v3 round-trip + HUD 短反馈(design §2.4-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/analyze-p52.py | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 scripts/analyze-p52.py (limited to 'scripts/analyze-p52.py') 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 + /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)) -- cgit v1.2.3