312 lines
14 KiB
GDScript
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],
|
|
},
|
|
}
|