diff --git a/docs/plans/M82_MARKET_EMPTY_STATE_REPAIR.md b/docs/plans/M82_MARKET_EMPTY_STATE_REPAIR.md index 9ea9fa89..4f2b23cf 100644 --- a/docs/plans/M82_MARKET_EMPTY_STATE_REPAIR.md +++ b/docs/plans/M82_MARKET_EMPTY_STATE_REPAIR.md @@ -26,6 +26,17 @@ Market 页在三种状态下各自成立: (全局 145.6..181.6);**分隔横线在 `band.end.y-6` → 全局 175.6**。 - 结论:横线落在标题槽内,130% 字号时压到文字 → 需按实际尺寸计算标题槽(不得写死分数)。 +## 最终 authored bounds(2026-09-17,实测后重记) + +- 外层 `MarketContextSurface`(`M52MarketSlotAdapter.ledger_area`):**`Rect2(0.630, 0.125, 0.345, 0.715)`** + —— 上移至顶栏下方、下抵 footer 上方,为 130% 让出纵向空间(1280×720 时 806.4, 90.0 / 441.6×514.8)。 +- `ledger_stack` 的槽序与高度(`hired en@1.3`,ledger 441.6×514.8,band 67): + band→portrait→identity→profession→detail→trait→cost→dock 顺序固定,相邻不重叠、全部 contained; + **portrait ≥72 play-size(新硬断言)**,空间不足时按存在内容跳行(空 trait/profession 不占高)。 +- 行高来源:detail = `micro` token 实测行高 × (1 或 2 行) + 10;trait/cost/identity/profession 按 token×相对缩放并只保留实测所需。 +- 探针新增硬断言:portrait ≥72;icon 与 availability ink 不相交;availability 与 amount ink 不相交; + detail 无 overrun 且 `get_line_count() <= get_visible_line_count()`;空态 `chrome_variant == ledger_empty`。 + ## Ordered steps 1. **M82-A**:本计划(提交一)。 @@ -65,5 +76,8 @@ Market 页在三种状态下各自成立: - `M82-I01` Guild:`GuildKioskCopyArea` 文字落在作者化红帷幔 + 状态行字号/对比弱(另包)。 - `M82-I02` Member:详情区信息架构/密度不足(另包)。 +- `M82-I04` **OPEN(2026-09-17,owner 目检发现)**:中文 hired 的详情行出现语言混排 + (`999 Coin · 队伍 8 → 9`,货币词为英文)。属文案语义/本地化,M82 frozen scope 禁止顺手修改, + 登记为后续窄包处理(与 M61-I05/M80-I10 无耦合)。 - `M82-I03` Party:第 4 图标槽无 runtime content、`PartyCompanyValue` 锚位错位(另包,需 Owner 指定该槽事实)。 - 本包内发现的新问题只登记,不顺手修。 diff --git a/runtime/tools/m52_market_visual_probe.gd b/runtime/tools/m52_market_visual_probe.gd index 8211bcb0..8011e2d0 100644 --- a/runtime/tools/m52_market_visual_probe.gd +++ b/runtime/tools/m52_market_visual_probe.gd @@ -89,38 +89,57 @@ func _run() -> void: quit(0) func _dock_bounds(view: Control, ui_factor := 1.0) -> Dictionary: - # Node rects are authored in the 1280x720 design space (the canvas transform only - # scales rendering), so the position expectations are design-space constants and - # the hit-size floor is checked in play pixels through the class fit factor. + # The row lives inside the centred canvas; its authored x/y tokens are checked + # in canvas-authored space (inverse of the canvas transform), while the physical + # hit floor stays on real screen pixels. 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 canvas := view.find_child("MarketMasterCanvas", true, false) as Control + var to_authored := Transform2D.IDENTITY + if canvas != null: + to_authored = canvas.get_global_transform_with_canvas().affine_inverse() + var b_global: Rect2 = back.get_global_rect() + var r_global: Rect2 = ret.get_global_rect() + var b := _transform_rect(to_authored, b_global) + var r := _transform_rect(to_authored, r_global) var problems: Array = [] if int(b.position.x) < 40 or int(b.position.x) > 80: - problems.append("back_left=%d" % int(b.position.x)) + problems.append("back_left=%d (authored)" % 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)) + problems.append("return_right=%d (authored)" % 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)]) - var play_back := _play_size(back) - if play_back.y < 48.0: - problems.append("back_height=%.0fpx play (floor 48)" % play_back.y) + # Physical floor on real screen pixels; the authored ceiling stays on the + # canvas-authored rect (no scale factor, no tolerance multiplier). + if b_global.size.y < 48.0: + problems.append("back_height=%.0fpx play (floor 48)" % b_global.size.y) if b.size.y > 60.0: - problems.append("back_height=%d exceeds the 60px token" % int(b.size.y)) - var detail := "back x %d..%d y %d..%d | return x %d..%d y %d..%d" % [ + problems.append("back_height=%d authored exceeds the 60px token" % int(b.size.y)) + var detail := "authored back x %d..%d y %d..%d | return x %d..%d y %d..%d | play h=%.0f" % [ 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), + int(r.position.x), int(r.end.x), int(r.position.y), int(r.end.y), b_global.size.y, ] if not problems.is_empty(): return {"ok": false, "detail": "%s | %s" % [",".join(problems), detail]} return {"ok": true, "detail": detail} +func _transform_rect(xform: Transform2D, rect: Rect2) -> Rect2: + var a := xform * rect.position + var b_pt := xform * Vector2(rect.end.x, rect.position.y) + var c := xform * Vector2(rect.position.x, rect.end.y) + var d := xform * rect.end + var xs := [a.x, b_pt.x, c.x, d.x] + var ys := [a.y, b_pt.y, c.y, d.y] + xs.sort() + ys.sort() + return Rect2(xs[0], ys[0], xs[xs.size() - 1] - xs[0], ys[ys.size() - 1] - ys[0]) + + 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] @@ -147,32 +166,39 @@ func _capture_fixture(width: int, height: int, locale: String, longest: bool, ki "variant": _variant_label(longest, kind, text_scale), "locale": locale, "size": "%dx%d" % [capture_size.x, capture_size.y], "detail": "requested %dx%d but captured %dx%d" % [width, height, capture_size.x, capture_size.y]}) - # The shipped window lays the 1280x720 design out and scales it into the real - # window (canvas_items + keep). A SubViewport is not a Window, so the same - # semantics are reproduced here: the page is authored on a 1280x720 stage and - # the stage is scaled/centred into the requested viewport. That keeps the - # authored contract identical across classes while PNG pixels follow `requested`. - var stage := Control.new() - stage.name = "M82DesignStage" - stage.size = Vector2(1280.0, 720.0) - sub.add_child(stage) - var fit := minf(float(width) / 1280.0, float(height) / 720.0) - var offset := Vector2((float(width) - 1280.0 * fit) * 0.5, (float(height) - 720.0 * fit) * 0.5) - sub.canvas_transform = Transform2D(Vector2(fit, 0.0), Vector2(0.0, fit), offset) + # M82: one geometry source only. The view enters the requested-size SubViewport + # first, then a full-rect preset with zeroed offsets owns its size; setting + # `view.size` as well doubled the layout (full anchors + size offsets). 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}) - stage.add_child(view) + sub.add_child(view) + view.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + view.offset_left = 0.0 + view.offset_top = 0.0 + view.offset_right = 0.0 + view.offset_bottom = 0.0 + await _settle() _note("%s before_render" % kind) view.render(_fixture_data(locale, longest, kind)) _note("%s after_render" % kind) await _settle() _note("%s after_first_settle" % kind) var label := _variant_label(longest, kind, text_scale) - await _save_image(sub, "%s/%s-%dx%d-%s-final.png" % [output_root, locale, capture_size.x, capture_size.y, label]) + var final_path := "%s/%s-%dx%d-%s-final.png" % [output_root, locale, capture_size.x, capture_size.y, label] + await _save_image(sub, final_path) + for problem in _edge_bar_problems(final_path): + if strict: + ink_violations.append({"node": "MarketCoverage", "kind": "bars", "path": kind, + "variant": label, "locale": locale, "size": "%dx%d" % [capture_size.x, capture_size.y], + "detail": problem}) + else: + print("ABG_M82_SMOKE_NOTE %dx%d %s %s bars: %s" % [capture_size.x, capture_size.y, locale, label, problem]) + _trace_coverage(view, Vector2(capture_size)) var violations := InkCheck.scan(view) + for problem in _coverage_problems(view, Vector2(capture_size)): + violations.append({"node": "MarketCoverage", "kind": "coverage", "path": kind, "detail": problem}) _note("%s after_scan" % kind) if strict: for problem in _state_problems(view, kind, locale, text_scale): @@ -481,6 +507,74 @@ func _trace_stack(ledger: Control, kind: String, locale: String, scale: float) - if node != null: print("ABG_M82_TRACE global %-16s pos=%s size=%s" % [node_name, str(node.global_position), str(node.size)]) +func _coverage_problems(view: Control, viewport_size: Vector2) -> Array: + # AGENTS: centred design canvas for geometry, aspect-cover for the world + # background. Everything is compared in the capture viewport's own space. + var problems: Array = [] + var view_rect := view.get_global_rect() + if absf(view_rect.position.x) > 1.0 or absf(view_rect.position.y) > 1.0 \ + or absf(view_rect.size.x - viewport_size.x) > 1.0 or absf(view_rect.size.y - viewport_size.y) > 1.0: + problems.append("view %s != viewport %s" % [str(view_rect), str(viewport_size)]) + var background := view.find_child("M52MarketWorldBackground", true, false) as Control + if background == null: + problems.append("world background node missing") + else: + var bg := background.get_global_rect() + if absf(bg.position.x) > 1.0 or absf(bg.position.y) > 1.0 \ + or absf(bg.size.x - viewport_size.x) > 1.0 or absf(bg.size.y - viewport_size.y) > 1.0: + problems.append("world background %s != viewport %s" % [str(bg), str(viewport_size)]) + var canvas := view.find_child("MarketMasterCanvas", true, false) as Control + if canvas == null: + problems.append("master canvas node missing") + else: + var art := canvas.get_global_rect() + var expect_h := minf(viewport_size.y, viewport_size.x * 9.0 / 16.0) + var expect_w := expect_h * 16.0 / 9.0 + var expect_x := (viewport_size.x - expect_w) * 0.5 + if absf(art.position.x - expect_x) > 1.5 or absf(art.size.x - expect_w) > 1.5 or absf(art.size.y - expect_h) > 1.5: + problems.append("master canvas %s != centred 16:9 %s" % [str(art), + str(Rect2(expect_x, 0.0, expect_w, expect_h))]) + return problems + + +func _trace_coverage(view: Control, viewport_size: Vector2) -> void: + if not trace: + return + var background := view.find_child("M52MarketWorldBackground", true, false) as Control + var canvas := view.find_child("MarketMasterCanvas", true, false) as Control + print("ABG_M82_TRACE coverage viewport=%s view=%s background=%s canvas=%s" % [ + str(viewport_size), str(view.get_global_rect()), + str(background.get_global_rect()) if background != null else "missing", + str(canvas.get_global_rect()) if canvas != null else "missing"]) + + +func _edge_bar_problems(image_path: String) -> Array: + # A clear-colour bar on any edge fails the capture (aspect-cover background). + var problems: Array = [] + var image := Image.new() + if image.load(image_path) != OK: + problems.append("saved frame could not be read back") + return problems + var clear: Color = ProjectSettings.get_setting("rendering/environment/defaults/default_clear_color", Color(0.3, 0.3, 0.3)) + var w := image.get_width() + var h := image.get_height() + var edges := { + "left": [[1, h / 4], [1, h / 2], [1, (h * 3) / 4]], + "right": [[w - 2, h / 4], [w - 2, h / 2], [w - 2, (h * 3) / 4]], + "top": [[w / 4, 1], [w / 2, 1], [(w * 3) / 4, 1]], + "bottom": [[w / 4, h - 2], [w / 2, h - 2], [(w * 3) / 4, h - 2]], + } + for edge in edges: + var matches := 0 + for point in edges[edge]: + var px := image.get_pixel(int(point[0]), int(point[1])) + if absf(px.r - clear.r) < 0.02 and absf(px.g - clear.g) < 0.02 and absf(px.b - clear.b) < 0.02: + matches += 1 + if matches >= 2: + problems.append("%s edge shows the clear colour" % edge) + return problems + + func _trace_floors(ledger: Control, kind: String) -> void: if not trace or kind == "empty": return