Files
aetherbound-guild/runtime/tools/m52_market_visual_probe.gd
T

312 lines
14 KiB
GDScript

extends SceneTree
## GUI-only visual ownership probe for M52 Market. It writes static-only,
## dynamic-only-on-neutral and final composite PNGs for each shipped fixture.
const MarketPageView = preload("res://entry/views/market_page_view.gd")
const InkCheck = preload("res://tools/ui_ink_check.gd")
# Single authoritative size (owner policy): every page is authored at 1280x720.
const FIXTURES := [
{"width": 1280, "height": 720},
]
const LOCALES := ["zh_CN", "en"]
# Accessibility pass: 1.0 plus the shipped 130% text scale (M73).
const TEXT_SCALES := [1.0, 1.3]
var output_root := ""
var ink_scanned := 0
var ink_violations: Array = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
if OS.get_environment("ABG_EVIDENCE_DIR").is_empty():
push_error("Market visual probe requires the owned test-attempt runner")
quit(1)
return
output_root = OS.get_environment("ABG_EVIDENCE_DIR").path_join("market-visual-probe")
DirAccess.make_dir_recursive_absolute(output_root)
var self_test := InkCheck.self_test(root)
if self_test.size() < 3:
push_error("M59 ink self-test expected at least 3 violations, got %d" % self_test.size())
quit(1)
return
print("ABG_M59_INK_SELF_TEST_OK violations=%d" % self_test.size())
ink_scanned = 0
ink_violations = []
for fixture in FIXTURES:
for locale in LOCALES:
for scale in TEXT_SCALES:
await _capture_fixture(int(fixture.width), int(fixture.height), String(locale), false, "recruit", float(scale))
if float(scale) == 1.0:
await _capture_fixture(int(fixture.width), int(fixture.height), String(locale), true, "recruit", float(scale))
await _capture_fixture(int(fixture.width), int(fixture.height), String(locale), false, "equipment", float(scale))
await _capture_fixture(int(fixture.width), int(fixture.height), String(locale), true, "hired", float(scale))
if not ink_violations.is_empty():
for violation in ink_violations:
push_error("M59 ink violation %s %s %s %s [%s] %s: %s" % [
violation.variant, violation.locale, violation.size, violation.kind,
violation.path, violation.node, violation.detail,
])
print("ABG_M59_INK_CHECK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()])
quit(1)
return
print("ABG_M59_INK_CHECK_OK scanned=%d violations=0" % ink_scanned)
print("ABG_M52_MARKET_VISUAL_PROBE_OK fixtures=1 locales=2 variants=normal,longest,equipment,hired scans=%d views=static,dynamic,final" % ink_scanned)
quit(0)
func _dock_bounds(view: Control) -> Dictionary:
var back := view.find_child("MarketBackGuild", true, false) as Button
var ret := view.find_child("MarketReturnContract", true, false) as Button
if back == null or ret == null:
return {"ok": false, "detail": "missing dock command"}
var b: Rect2 = back.get_global_rect()
var r: Rect2 = ret.get_global_rect()
var problems: Array = []
if int(b.position.x) < 40 or int(b.position.x) > 80: problems.append("back_left=%d" % int(b.position.x))
if int(r.end.x) < 1200 or int(r.end.x) > 1272: problems.append("return_right=%d" % int(r.end.x))
if absf(b.size.y - r.size.y) > 2.0: problems.append("height %d vs %d" % [int(b.size.y), int(r.size.y)])
if absf(b.position.y - r.position.y) > 2.0: problems.append("baseline %d vs %d" % [int(b.position.y), int(r.position.y)])
if b.size.y < 48.0 or b.size.y > 60.0: problems.append("back_height=%d" % int(b.size.y))
var detail := "back x %d..%d y %d..%d | return x %d..%d y %d..%d" % [
int(b.position.x), int(b.end.x), int(b.position.y), int(b.end.y),
int(r.position.x), int(r.end.x), int(r.position.y), int(r.end.y),
]
if not problems.is_empty():
return {"ok": false, "detail": "%s | %s" % [",".join(problems), detail]}
return {"ok": true, "detail": detail}
func _variant_label(longest: bool, kind: String, text_scale: float = 1.0) -> String:
var base := kind if kind != "recruit" else ("longest" if longest else "normal")
return base if is_equal_approx(text_scale, 1.0) else "%s@%.1f" % [base, text_scale]
func _capture_fixture(width: int, height: int, locale: String, longest: bool, kind: String = "recruit", text_scale: float = 1.0) -> void:
root.size = Vector2(width, height)
var view := MarketPageView.new()
view.name = "MarketVisualProbeView"
view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
view.configure_presentation({"safe_area": 24, "text_scale": text_scale, "locale": locale})
root.add_child(view)
view.render(_fixture_data(locale, longest, kind))
await process_frame
await RenderingServer.frame_post_draw
var label := _variant_label(longest, kind, text_scale)
_save_image("%s/%s-%dx%d-%s-final.png" % [output_root, locale, width, height, label])
var violations := InkCheck.scan(view)
ink_scanned += 1
if label == "normal":
var dock := _dock_bounds(view)
if not bool(dock.get("ok", false)):
violations.append({"node": "MarketDockRow", "kind": "bounds", "path": "footer", "detail": String(dock.get("detail", ""))})
elif locale == "zh_CN" and is_equal_approx(text_scale, 1.0):
print("ABG_M52_MARKET_DOCK_BOUNDS_OK %s" % String(dock.get("detail", "")))
for violation in violations:
violation["variant"] = label
violation["locale"] = locale
violation["size"] = "%dx%d" % [width, height]
ink_violations.append(violation)
var page := view.find_child("M52MarketDesignPage", true, false) as Control
if page != null:
page.visible = false
await process_frame
await RenderingServer.frame_post_draw
_save_image("%s/%s-%dx%d-%s-static.png" % [output_root, locale, width, height, label])
if page != null:
page.visible = true
var background := view.find_child("M52MarketWorldBackground", true, false) as Control
var matte := view.find_child("MarketMasterMatte", true, false) as Control
if background != null:
background.visible = false
if matte != null:
matte.visible = false
var neutral := ColorRect.new()
neutral.name = "NeutralProbeBackground"
neutral.color = Color("#26353c")
neutral.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
neutral.mouse_filter = Control.MOUSE_FILTER_IGNORE
view.add_child(neutral)
view.move_child(neutral, 0)
await process_frame
await RenderingServer.frame_post_draw
_save_image("%s/%s-%dx%d-%s-dynamic.png" % [output_root, locale, width, height, label])
view.queue_free()
await process_frame
func _save_image(path: String) -> void:
var texture := root.get_viewport().get_texture()
if texture != null:
texture.get_image().save_png(path)
func _fixture_data(locale: String, longest := false, kind: String = "recruit") -> Dictionary:
if kind == "equipment":
return _equipment_fixture(locale, longest)
if kind == "hired":
return _hired_fixture(locale)
var is_zh := locale == "zh_CN"
var names := (
["卡珊德拉", "奥莉薇娅", "风语者"]
if longest and is_zh
else ["Gannet Faircurrent", "Cassa Stonebloom", "Hessa Quillwater"]
if longest
else ["阿里", "布兰", "塞拉"] if is_zh else ["Ari", "Bram", "Cera"]
)
return {
"ok": true,
"page_id": "market",
"locale": locale,
"category": "recruit",
"selected_kind": "recruit",
"selected_offer_id": "R-001",
"selected_offer": _recruit("R-001", names[0], locale, longest),
"offers": {
"recruit": [
_recruit("R-001", names[0], locale, longest),
_recruit("R-002", names[1], locale, longest),
_recruit("R-003", names[2], locale, longest),
],
},
"available": {"recruit": ["R-001", "R-002", "R-003"]},
"company": (
{"owned_count": 8, "line_capacity": 8, "coin": 999}
if longest
else {"owned_count": 2, "line_capacity": 4, "coin": 38}
),
"commands": {"return_contract": {"enabled": true, "reason": "ready"}},
}
func _hired_fixture(locale: String) -> Dictionary:
# M60-I03: one offer is already hired; the card must say so clearly while
# staying inspectable, and the ledger must read HIRED.
var is_zh := locale == "zh_CN"
var hired_offer := _recruit("R-001", "甘尼特", locale, true)
var available_offer := _recruit("R-002", "卡珊德拉", locale, true)
var data := _fixture_data(locale, true, "recruit")
data["offers"] = {"recruit": [hired_offer, available_offer]}
data["selected_offer_id"] = "R-001"
data["selected_offer"] = hired_offer
data["available"] = {"recruit": ["R-002"]}
data["review"] = {"available": true, "action": "recruit_review", "reason": "inspectable"}
return data
func _equipment_fixture(locale: String, longest: bool) -> Dictionary:
# Real catalog identity: EQ-002 定步护胫 / Frontpin Greaves and EQ-012
# 开线绳结 / Open-Line Cord with their shipped effect/fit/tradeoff copy.
var is_zh := locale == "zh_CN"
var items := [
{
"catalog_id": "EQ-002",
"name": "定步护胫" if is_zh else "Frontpin Greaves",
"effect": "在完成一次基础行动前,抵抗敌方第一次推动。" if is_zh else "Resists the enemy's first push until a basic action completes.",
"fit": "服装;前排" if is_zh else "Clothing; front line",
"tradeoff": "完成该基础行动后自身迟滞 1 tick。" if is_zh else "The wearer lags 1 tick after that action.",
"price": 12,
},
{
"catalog_id": "EQ-012",
"name": "开线绳结" if is_zh else "Open-Line Cord",
"effect": "前方没有相邻队友时,基础行动射程 +1。" if is_zh else "Basic action range +1 while no ally stands ahead.",
"fit": "饰品;任意位置" if is_zh else "Trinket; any slot",
"tradeoff": "前方出现队友时,立即取消已保留的远距目标。" if is_zh else "An ally moving ahead cancels the held far target.",
"price": 999 if longest else 18,
},
]
var offers: Array = []
var available: Array = []
for index in range(4):
var item: Dictionary = items[index % items.size()]
var offer_id := "E-%03d" % (index + 1)
offers.append({
"offer_id": offer_id,
"catalog_id": String(item.catalog_id),
"list_price": int(item.price),
"presentation": {
"title": String(item.name),
"detail": "%s\n%s\n%s" % [String(item.effect), String(item.fit), String(item.tradeoff)],
"short_detail": "%s\n%s" % [String(item.name), String(item.effect)],
"price": "%d %s" % [int(item.price), "金币" if is_zh else "COIN"],
"state": "inspectable",
"state_text": "可检查" if is_zh else "INSPECTABLE",
"recruit_name": "",
"profession_name": "",
"trait_name": "",
},
})
available.append(offer_id)
return {
"ok": true,
"page_id": "market",
"locale": locale,
"category": "equipment",
"selected_kind": "equipment",
"selected_offer_id": "E-001",
"selected_offer": offers[0],
"offers": {"equipment": offers},
"available": {"equipment": available},
"company": {"owned_count": 2, "line_capacity": 4, "coin": 38},
"commands": {"return_contract": {"enabled": true, "reason": "ready"}},
"review": {"available": true, "action": "equipment_review", "reason": "inspectable"},
}
func _recruit(offer_id: String, recruit_name: String, locale: String, longest := false) -> Dictionary:
var is_zh := locale == "zh_CN"
# Real per-offer identity: three different base professions so the board
# renders three different portraits. A single hardcoded profession_id made
# the capture show three copies of one recruit.
var index := int(offer_id.trim_prefix("R-"))
var slot := clampi(index - 1, 0, 2)
var profession_ids := ["PF-B01", "PF-B05", "PF-B07"]
var zh_professions := ["盾卫", "弓手", "治疗"]
var en_professions := ["Hearthguard", "Farstring", "Kindhand"]
var zh_traits := ["最后见证", "稳固阵线", "夜航直觉"]
var en_traits := ["Last Witness", "Steady Line", "Night Sense"]
var list_prices := [14, 18, 22]
var company_line := "队伍 2 → 3" if slot == 0 else "队伍 2 → 3"
var company_line_en := "Company 2 -> 3"
if longest:
# Longest shipped strings measured from the runtime catalog: base
# professions 护心卫士/逐风行者/远弦射手 and Hearthguard/Wayrunner/
# Farstring, traits 反转带扣/Turncoat Buckle (15 glyphs), recruit names
# from the display-name pool, three-digit coin and a full 8/8 line.
profession_ids = ["PF-B01", "PF-B04", "PF-B05"]
zh_professions = ["护心卫士", "逐风行者", "远弦射手"]
en_professions = ["Hearthguard", "Wayrunner", "Farstring"]
zh_traits = ["反转带扣", "松散阵形", "最后见证"]
en_traits = ["Turncoat Buckle", "Loose Formation", "Last Witness"]
list_prices = [999, 998, 997]
company_line = "队伍 8 → 9"
company_line_en = "Company 8 -> 9"
# The production context line is two short rows built by the Market
# controller ("profession · trait" then price and Company growth). Keep the
# fixture in that authored shape so the ledger slot is exercised honestly.
var zh_details := [
"%s · %s\n%d Coin · %s" % [zh_professions[slot], zh_traits[slot], list_prices[slot], company_line],
"%s · %s\n%d Coin · %s" % [zh_professions[1], zh_traits[1], list_prices[1], company_line],
"%s · %s\n%d Coin · %s" % [zh_professions[2], zh_traits[2], list_prices[2], company_line],
]
var en_details := [
"%s · %s\n%d COIN · %s" % [en_professions[slot], en_traits[slot], list_prices[slot], company_line_en],
"%s · %s\n%d COIN · %s" % [en_professions[1], en_traits[1], list_prices[1], company_line_en],
"%s · %s\n%d COIN · %s" % [en_professions[2], en_traits[2], list_prices[2], company_line_en],
]
return {
"source_offer_id": offer_id,
"display_name_tokens": [recruit_name],
"recruit_name": recruit_name,
"profession_id": profession_ids[slot],
"trait_id": "TR-%03d" % (slot + 1),
"list_price": list_prices[slot],
"presentation": {
"recruit_name": recruit_name,
"profession_name": zh_professions[slot] if is_zh else en_professions[slot],
"trait_name": zh_traits[slot] if is_zh else en_traits[slot],
"short_detail": zh_details[slot] if is_zh else en_details[slot],
},
}