extends SceneTree const GuildView = preload("res://entry/views/guild_page_view.gd") const InkCheck = preload("res://tools/ui_ink_check.gd") const FIXTURES := [Vector2i(1280, 720)] const LOCALES := ["zh_CN", "en"] # Accessibility pass: 1.0 plus the shipped 130% text scale (M73). const TEXT_SCALES := [1.0, 1.3] # M84-A measured master regions on the 1280x720 canvas (master px x 0.625). const MASTER_PATH := "res://assets/backgrounds/m52_q_guild_background.png" const PAPER := Rect2(838.0, 144.0, 1172.0 - 838.0, 347.0 - 144.0) const PLATE := Rect2(878.0, 139.0, 1185.0 - 878.0, 355.0 - 139.0) const CURTAIN := Rect2(812.0, 119.0, 1187.0 - 812.0, 181.0 - 119.0) const CONTRAST_MIN := 4.5 const CONTRAST_LABELS := ["GuildKioskText", "GuildContractReason", "GuildMarketReason", "GuildNotice", "GuildProgressStatus", "GuildCoinStatus", "GuildAnchorCompanyStatus"] var master_image: Image var m84_failures: Array = [] var notice_node_path := "(unresolved)" var ratio_reports: Array = [] var ink_scanned := 0 var probe_text_scale := 1.0 var ink_violations: Array = [] func _initialize() -> void: call_deferred("_run") func _run() -> void: var out := OS.get_environment("ABG_EVIDENCE_DIR") if out.is_empty(): push_error("requires ABG_EVIDENCE_DIR") quit(1) return DirAccess.make_dir_recursive_absolute(out) var ink_self := InkCheck.self_test(root) if ink_self.size() < 3: push_error("M62 ink self-test expected at least 3 violations, got %d" % ink_self.size()) quit(1) return print("ABG_M62_INK_SELF_TEST_OK violations=%d" % ink_self.size()) ink_scanned = 0 ink_violations = [] for fixture in FIXTURES: for locale in LOCALES: for text_scale in TEXT_SCALES: probe_text_scale = float(text_scale) await _capture_fixture(fixture, locale, out) var report_path := OS.get_environment("ABG_EVIDENCE_DIR").path_join("ink-violations-%s.json" % _probe_id()) var file := FileAccess.open(report_path, FileAccess.WRITE) if file != null: file.store_string(JSON.stringify(ink_violations, " ")) file.close() if ink_violations.is_empty() and m84_failures.is_empty(): print("ABG_M84_CONTRAST_RATIOS %s" % " | ".join(ratio_reports)) print("ABG_M84_NOTICE_SEMANTIC_OK ink=color_danger_deep#8c2f2f paper=cream source=single node=%s" % _notice_path()) print("ABG_M84_GUILD_BOARD_OK paper=%s curtain_clear=true contrast_min=%.1f" % [str(PAPER), CONTRAST_MIN]) print("ABG_M53_GUILD_INK_OK scanned=%d violations=0" % ink_scanned) else: for failure in m84_failures: push_error("M84-A guild board: %s" % failure) for violation in ink_violations: push_error("M53 ink violation %s: text=%s slot=%s font=%s" % [String(violation.get("node", "?")), String(violation.get("text_width", "?")), String(violation.get("slot_width", "?")), String(violation.get("font_size", "?"))]) print("ABG_M53_GUILD_INK_FAIL scanned=%d violations=%d" % [ink_scanned, ink_violations.size()]) quit(1) return print("ABG_M53_GUILD_VISUAL_PROBE_OK states=members locales=%d scans=%d views=static,dynamic,final" % [LOCALES.size(), ink_scanned]) quit(0) func _probe_id() -> String: return "m53-guild" func _scan_ink(view: Control, locale: String, label: String) -> void: # Legacy-chrome pages (pre-M62) record their ink defects as a baseline for # the W5 migration; migrated pages (Market, Readiness once moved) must be # strict. The report file is the machine-readable work list. var violations := InkCheck.scan(view) ink_scanned += 1 for violation in violations: violation["locale"] = locale violation["fixture"] = label ink_violations.append(violation) func _capture_fixture(fixture: Vector2i, locale: String, out: String) -> void: root.size = Vector2(fixture) var capture_viewport := SubViewport.new() capture_viewport.name = "GuildCaptureViewport" capture_viewport.size = fixture capture_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS capture_viewport.transparent_bg = false root.add_child(capture_viewport) var view := GuildView.new() view.name = "GuildVisualProbeView" view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) view.configure_presentation({"safe_area": 24, "text_scale": probe_text_scale, "locale": locale}) capture_viewport.add_child(view) view.render({ "ok": true, "locale": locale, "company": { "owned_count": 2, "coin": 38, "display_recruits": [ {"recruit_id": "owned-001", "profession_id": "PF-B01", "rendered_name_snapshot": {"en": "Ari", "zh_CN": "阿里"}, "profession_name": {"en": "Vanguard", "zh_CN": "先锋"}}, {"recruit_id": "owned-002", "profession_id": "PF-B06", "rendered_name_snapshot": {"en": "Bram", "zh_CN": "布拉姆"}, "profession_name": {"en": "Arcanist", "zh_CN": "奥术师"}}, ], }, "destinations": {}, "recruits": [], "notice": "同名委托需先返回再接受" if locale == "zh_CN" else "Return before accepting the same contract", }) await _settle() _scan_ink(view, locale, "%dx%d" % [fixture.x, fixture.y]) _save(capture_viewport, out, locale, fixture, "final") _check_m84(view, locale, out.path_join("guild-%dx%d-%s-final.png" % [fixture.x, fixture.y, locale])) var overlay := view.find_child("GuildAnchorRoot", true, false) as Control if overlay != null: overlay.visible = false await _settle() _save(capture_viewport, out, locale, fixture, "static") if overlay != null: overlay.visible = true var world := view.find_child("GuildMasterWorldExtension", true, false) as Control if world != null: world.visible = false await _settle() _save(capture_viewport, out, locale, fixture, "dynamic") view.queue_free() capture_viewport.queue_free() await process_frame func _master() -> Image: if master_image == null: master_image = Image.new() master_image.load(MASTER_PATH) return master_image func _rect_of(view: Control, node_name: String) -> Rect2: var node := view.find_child(node_name, true, false) as Control if node == null: return Rect2() # Global rects are already in 1280x720 canvas pixels; only the master image # sample below needs the x1.6 master scale. return node.get_global_rect() func _ink_rect(view: Control, node_name: String) -> Rect2: var node := view.find_child(node_name, true, false) as Control if node == null: return Rect2() var label := node as Label if label != null: return InkCheck.ink_rect(label) return _rect_of(view, node_name) func _luminance(color: Color) -> float: var channels := [color.r, color.g, color.b] var linear: Array = [] for value in channels: linear.append(value / 12.92 if value <= 0.03928 else pow((value + 0.055) / 1.055, 2.4)) return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] func _carrier_p95(image: Image, rect: Rect2) -> Color: # 95th-percentile luminance carrier inside the label band: the surface the glyphs # actually sit on, robust to dark corners. var samples: Array = [] var x0 := clampi(int(rect.position.x), 0, image.get_width() - 1) var x1 := clampi(int(rect.end.x), 0, image.get_width() - 1) var y0 := clampi(int(rect.position.y), 0, image.get_height() - 1) var y1 := clampi(int(rect.end.y), 0, image.get_height() - 1) for x in range(x0, maxi(x0 + 1, x1), 2): for y in range(y0, maxi(y0 + 1, y1), 2): samples.append(image.get_pixel(x, y)) if samples.is_empty(): return Color.BLACK samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b)) return samples[mini(samples.size() - 1, int(float(samples.size() - 1) * 0.95))] func _notice_path() -> String: return notice_node_path func _check_m84(view: Control, locale: String, frame_path: String) -> void: var inset := 4.0 var copy := _rect_of(view, "GuildKioskCopyArea") if copy.size.x <= 0.0: m84_failures.append("%s copy area missing" % locale) return var inner := Rect2(PLATE.position + Vector2(inset, inset), PLATE.size - Vector2(inset * 2.0, inset * 2.0)) if not inner.encloses(copy): m84_failures.append("%s copy area %s escapes the plate inset %s" % [locale, str(copy), str(inner)]) if copy.position.y < PLATE.position.y: m84_failures.append("%s copy area top %.1f above the plate top %.1f" % [locale, copy.position.y, PLATE.position.y]) if CURTAIN.intersects(copy): m84_failures.append("%s copy area intersects the red curtain" % locale) for child in ["GuildKioskText", "GuildContractReason", "GuildMarketReason", "GuildNotice"]: # M84-E: the board used to carry two same-named node sets, one hidden, and # every name lookup measured the hidden twin while the visible one painted. # The single-source rule is a gate from now on. var matches := view.find_children(child, "", true, false) if matches.size() != 1: m84_failures.append("%s %s has %d same-named nodes (single source required)" % [locale, child, matches.size()]) continue var resolved := matches[0] as Label if child == "GuildNotice" and resolved != null: notice_node_path = String(resolved.get_path()) if resolved == null or not resolved.is_visible_in_tree(): m84_failures.append("%s %s is not a visible label (hidden twin?)" % [locale, child]) continue var rect := _rect_of(view, child) var ink := _ink_rect(view, child) if rect.size.x <= 0.0: m84_failures.append("%s %s missing" % [locale, child]) continue if not inner.encloses(rect): m84_failures.append("%s %s rect %s escapes the plate inset" % [locale, child, str(rect)]) if not inner.encloses(ink): m84_failures.append("%s %s ink %s escapes the plate inset" % [locale, child, str(ink)]) if CURTAIN.intersects(rect) or CURTAIN.intersects(ink): m84_failures.append("%s %s intersects the red curtain" % [locale, child]) if not PAPER.encloses(_rect_of(view, "GuildNotice")): m84_failures.append("%s GuildNotice is not on the paper" % locale) # M84-D: the notice is a warning and must keep the semantic danger ink. var notice_label := view.find_child("GuildNotice", true, false) as Label if notice_label == null: m84_failures.append("%s GuildNotice missing for the semantic check" % locale) elif notice_label.get_theme_color("font_color") != Color("#8c2f2f"): m84_failures.append("%s GuildNotice ink is not COLOR_DANGER" % locale) # Contrast is measured on the rendered frame: the top-rail plates only exist at # runtime, so sampling the master art would misjudge them. The background probe # point sits just below the ink, on the same carrier surface. var image := Image.new() if image.load(frame_path) != OK: m84_failures.append("%s rendered frame could not be read" % locale) return for node_name in CONTRAST_LABELS: var label := view.find_child(node_name, true, false) as Label if label == null: m84_failures.append("%s %s missing for contrast" % [locale, node_name]) continue if label.text.strip_edges().is_empty(): continue var ink := _ink_rect(view, node_name) # M84-D: sample the actual carrier under the text run. The four inset corners # of the label rect sit on the carrier (glyphs are centred), and the median # luminance represents it without catching a neighbouring shadow or stroke. var rect := _rect_of(view, node_name) var candidates := [ Vector2i(int(rect.position.x) + 4, int(rect.position.y) + 2), Vector2i(int(rect.end.x) - 4, int(rect.position.y) + 2), Vector2i(int(rect.position.x) + 4, int(rect.end.y) - 2), Vector2i(int(rect.end.x) - 4, int(rect.end.y) - 2), ] var samples: Array = [] for point in candidates: var px := clampi(point.x, 0, image.get_width() - 1) var py := clampi(point.y, 0, image.get_height() - 1) samples.append(image.get_pixel(px, py)) samples.sort_custom(func(a, b): return _luminance(a) < _luminance(b)) # Carrier = the band's P95 luminance (the surface the glyphs sit on). The # corner minimum measures the paper's shadowed margin instead, which M84-D # showed can under-read a warning line by ~0.5 ratio points. var background: Color = _carrier_p95(image, rect) var foreground: Color = label.get_theme_color("font_color") var lighter := maxf(_luminance(foreground), _luminance(background)) var darker := minf(_luminance(foreground), _luminance(background)) var ratio := (lighter + 0.05) / (darker + 0.05) # Diagnostics: the corner samples and the P95 carrier value make a failure # readable without digging into the frame by hand. var p95 := _carrier_p95(image, rect) ratio_reports.append("%s %s=%.2f:1 (p95=#%s)" % [locale, node_name, ratio, p95.to_html(false)]) if ratio < CONTRAST_MIN: var corners := PackedStringArray() for sample_px in samples: corners.append("#%s" % sample_px.to_html(false)) m84_failures.append("%s %s contrast %.2f:1 below %.1f:1 corners=[%s] p95=#%s" % [ locale, node_name, ratio, CONTRAST_MIN, ", ".join(corners), p95.to_html(false)]) func _settle() -> void: for _i in range(3): await process_frame func _save(viewport: SubViewport, out: String, locale: String, fixture: Vector2i, kind: String) -> void: var viewport_texture := viewport.get_texture() if viewport_texture == null: return var image := viewport_texture.get_image() if image != null: image.save_png(out.path_join("guild-%dx%d-%s-%s.png" % [fixture.x, fixture.y, locale, kind]))