exchanger-themes/tools/validate_theme.gd
Danil Iarosh ca52efbedc Initial commit: Exchanger Theme DLC Builder
Godot 4.7 project for authoring, previewing, validating, and building Exchanger PCK themes. Includes the example and space-pixel themes, binding contracts, editor tooling, validation/build scripts, and project documentation.
2026-09-22 11:43:13 +03:00

910 lines
41 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends SceneTree
const DEFAULT_CONTRACT := "res://contracts/screen_bindings.v2.json"
const MANIFEST_PATH := "res://exchanger_theme_dlc/manifest.json"
const PACKAGED_CONTRACT_PATH := "res://exchanger_theme_dlc/contracts/screen_bindings.v2.json"
const SELF_CONTAINED_VISUALS_META := &"exchanger_self_contained_visuals"
const CANONICAL_PACKAGE_ROOT := "res://exchanger_theme_dlc/"
const INTERACTIVE_PET_MANIFEST_FIELD := "InteractivePetScene"
const INTERACTIVE_PET_METHODS := [
&"get_pet_animation_names",
&"get_pet_animation_frame_count",
&"has_pet_animation",
&"play_pet_animation",
&"pause_pet_animation",
&"stop_pet_animation",
&"get_current_pet_animation",
&"get_current_pet_frame",
]
const INTERACTIVE_PET_COMPOSITE_METHODS := [
&"has_pet_composite_action",
&"start_pet_composite_action",
&"cancel_pet_composite_action",
]
const INTERACTIVE_PET_COMPOSITE_SIGNALS := [
&"pet_composite_action_started",
&"pet_composite_action_finished",
]
const INTERACTIVE_PET_ACTIONS := [
&"idle",
&"sit",
&"interact",
&"walk_left",
&"walk_right",
&"walk_start_left",
&"walk_start_right",
&"walk_finish_left",
&"walk_finish_right",
&"climb_start",
&"climb_up",
&"climb_down",
&"climb_finish",
&"drag",
&"drop",
&"landing",
&"celebrate",
]
const PET_NAVIGATION_ROOT_META := &"exchanger_pet_navigation_root"
const PET_SURFACE_META := &"exchanger_pet_surface"
const PET_SURFACE_VISIBILITY_BINDING_META := &"exchanger_pet_surface_visibility_binding_id"
const PET_CLIMB_EDGE_META := &"exchanger_pet_climb_edge"
const IMMUTABLE_BINDING_COUNT := 481
const SUPPLEMENTAL_RUNTIME_BINDINGS := {
"screen.home": {
"home.scroll.content.advertisement_panel": ["Control"],
"home.scroll.content.stock_status.meter.empty": ["Control"],
"home.scroll.content.stock_status.meter.low": ["Control"],
"home.scroll.content.stock_status.meter.enough": ["Control"],
"home.scroll.content.stock_status.meter.much": ["Control"],
"home.scroll.content.stock_status.approximate_count": ["Label"],
},
"screen.card_amount": {
"card_amount.content.reward_panel.content.bonus": ["Label"],
"card_amount.content.top_bar.home_navigation_button": ["Button"],
},
"screen.cash_payment": {
"cash_payment.content.top_bar.home_navigation_button": ["Button"],
},
"screen.card_custom_amount": {
"card_custom_amount.content.top_bar.home_navigation_button": ["Button"],
},
"screen.card_terminal": {
"card_terminal.content.top_bar.home_navigation_button": ["Button"],
},
"screen.success": {
"success.content.home_navigation_button": ["Button"],
},
"screen.error": {
"error.content.home_navigation_button": ["Button"],
},
"screen.service_access": {
"service_access.content.home_navigation_button": ["Button"],
},
"screen.settings": {
"settings.footer": ["PanelContainer"],
"settings.footer.save_and_exit_button": ["Button"],
"settings.scroll.content.footer_clearance": ["Control"],
"settings.scroll.content.service_inventory_panel.margin.content.description": ["Label"],
"settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled": ["CheckButton"],
"settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled": ["CheckButton"],
"settings.scroll.content.menu_visibility_panel": ["PanelContainer"],
"settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status": ["CheckButton"],
"settings.scroll.content.menu_visibility_panel.margin.content.show_right_character": ["CheckButton"],
"settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block": ["CheckButton"],
},
}
const OPTIONAL_SUPPLEMENTAL_RUNTIME_BINDINGS := {
"screen.cash_payment": {
"cash_payment.content.banner_text": ["Label"],
},
"screen.home": {
"home.decoration.custom_text_block": ["Control"],
"home.decoration.right_character": ["TextureRect"],
},
"screen.settings": {
"settings.scroll.content.animated_banner_texts_panel": ["Control"],
"settings.scroll.content.advertisement_poster_panel.margin.content.select_button": ["Button"],
"settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet": ["CheckButton"],
},
}
const HOME_PET_SURFACE_VISIBILITY_BINDINGS := {
&"stock_window": "home.scroll.content.stock_status",
&"advertisement_window": "home.scroll.content.advertisement_panel",
}
var _errors: PackedStringArray = []
func _init() -> void:
call_deferred("_validate")
func _validate() -> void:
var contract_path := _argument_value("--contract", DEFAULT_CONTRACT)
var contract := _load_json(contract_path)
_expect(FileAccess.file_exists(PACKAGED_CONTRACT_PATH), "Packaged binding catalog is missing: %s" % PACKAGED_CONTRACT_PATH)
if FileAccess.file_exists(PACKAGED_CONTRACT_PATH):
_expect(
FileAccess.get_sha256(contract_path) == FileAccess.get_sha256(PACKAGED_CONTRACT_PATH),
"Packaged binding catalog differs from the builder authority"
)
var manifest := _load_json(MANIFEST_PATH)
if contract.is_empty() or manifest.is_empty():
_finish()
return
var binding_count := 0
for screen_value in contract.get("Screens", []):
binding_count += (screen_value as Dictionary).get("Bindings", []).size()
_expect(
binding_count == IMMUTABLE_BINDING_COUNT,
"immutable ThemeView contract must contain %d bindings; found %d"
% [IMMUTABLE_BINDING_COUNT, binding_count]
)
_expect(manifest.get("Format") == "ExchangerThemeDlc", "manifest Format must be ExchangerThemeDlc")
_expect(int(manifest.get("FormatVersion", 0)) == 2, "manifest FormatVersion must be 2")
_expect(
str(manifest.get("BackgroundTexturePath", "")).is_empty(),
"self-contained themes must keep manifest BackgroundTexturePath empty"
)
_expect(
str(manifest.get("BackgroundDecorationPath", "")).is_empty(),
"self-contained themes must keep manifest BackgroundDecorationPath empty"
)
_validate_interactive_pet(manifest)
var has_interactive_pet := not str(manifest.get(INTERACTIVE_PET_MANIFEST_FIELD, "")).is_empty()
var screen_map: Dictionary = manifest.get("ScreenScenes", {})
var global_bindings: Dictionary = {}
for screen_value in contract.get("Screens", []):
var screen: Dictionary = screen_value
var screen_id := str(screen.get("BindingId", ""))
var expected_path := str(screen.get("Path", ""))
var actual_path := str(screen_map.get(screen_id, ""))
_expect(actual_path == expected_path, "%s must map to %s" % [screen_id, expected_path])
if actual_path != expected_path or not ResourceLoader.exists(actual_path):
_error("Scene is missing: %s" % expected_path)
continue
var packed := load(actual_path) as PackedScene
if packed == null:
_error("Scene is not a PackedScene: %s" % actual_path)
continue
var root := packed.instantiate()
if root == null:
_error("Scene cannot be instantiated: %s" % actual_path)
continue
_expect(root.name == StringName(str(screen.get("RootName", ""))), "%s root must be named %s" % [screen_id, screen.get("RootName", "")])
var screen_metadata_key := str(contract.get("ScreenMetadataKey", "exchanger_screen_binding_id"))
_expect(str(root.get_meta(screen_metadata_key, "")) == screen_id, "%s root metadata must equal its screen binding id" % screen_id)
_validate_self_contained_visuals(
root,
screen_id,
bool(manifest.get("UseNearestTextureFilter", false))
)
if has_interactive_pet and screen_id != "screen.settings":
_validate_pet_navigation(root, screen_id)
_validate_bindings(root, screen, global_bindings, str(contract.get("ElementMetadataKey", "exchanger_binding_id")))
_validate_fixed_slot_defaults(root, screen_id, str(contract.get("ElementMetadataKey", "exchanger_binding_id")))
root.free()
for manifest_screen in screen_map:
if not _contract_has_screen(contract, str(manifest_screen)):
_error("Unknown ScreenScenes key: %s" % manifest_screen)
_finish()
func _validate_interactive_pet(manifest: Dictionary) -> void:
var scene_path := str(manifest.get(INTERACTIVE_PET_MANIFEST_FIELD, ""))
if scene_path.is_empty():
return
_expect(
scene_path.begins_with(CANONICAL_PACKAGE_ROOT),
"%s must point inside %s" % [INTERACTIVE_PET_MANIFEST_FIELD, CANONICAL_PACKAGE_ROOT]
)
_expect(not scene_path.contains(".."), "%s must not contain '..'" % INTERACTIVE_PET_MANIFEST_FIELD)
_expect(scene_path.ends_with(".tscn"), "%s must point to a .tscn scene" % INTERACTIVE_PET_MANIFEST_FIELD)
if not scene_path.begins_with(CANONICAL_PACKAGE_ROOT) or not ResourceLoader.exists(scene_path):
_error("Interactive pet scene is missing: %s" % scene_path)
return
var packed_scene := load(scene_path) as PackedScene
if packed_scene == null:
_error("Interactive pet resource is not a PackedScene: %s" % scene_path)
return
var pet := packed_scene.instantiate()
if pet == null:
_error("Interactive pet scene cannot be instantiated: %s" % scene_path)
return
_expect(pet is Node2D, "interactive pet root must inherit Node2D")
for method_name in INTERACTIVE_PET_METHODS:
_expect(pet.has_method(method_name), "interactive pet must expose %s()" % method_name)
_expect(pet.has_signal(&"pet_animation_started"), "interactive pet must expose pet_animation_started")
_expect(pet.has_signal(&"pet_animation_completed"), "interactive pet must expose pet_animation_completed")
_validate_optional_pet_profile(pet)
pet.free()
func _validate_optional_pet_profile(pet: Node) -> void:
var animation_names: Array[StringName] = []
var raw_animation_names = pet.call(&"get_pet_animation_names")
if raw_animation_names is Array:
for raw_name in raw_animation_names:
animation_names.append(StringName(str(raw_name)))
if pet.has_method(&"get_pet_action_animation"):
for action_name in INTERACTIVE_PET_ACTIONS:
var animation_name := StringName(str(pet.call(&"get_pet_action_animation", action_name)))
if not animation_name.is_empty():
_expect(
animation_names.has(animation_name),
"interactive pet action %s maps to unknown animation %s" % [action_name, animation_name]
)
_validate_optional_composite_pet_action(pet)
_validate_optional_positive_float(pet, &"get_pet_runtime_scale")
_validate_optional_positive_float(pet, &"get_pet_walk_speed")
_validate_optional_positive_float(pet, &"get_pet_climb_speed")
var reference_size := Vector2.ZERO
if pet.has_method(&"get_pet_hit_size"):
var hit_size = pet.call(&"get_pet_hit_size")
_expect(
hit_size is Vector2
and _is_finite_vector2(hit_size)
and hit_size.x > 0.0
and hit_size.y > 0.0,
"get_pet_hit_size() must return a finite positive Vector2"
)
if hit_size is Vector2:
reference_size = hit_size
elif pet.has_method(&"get_pet_preview_size"):
var preview_size = pet.call(&"get_pet_preview_size")
if preview_size is Vector2:
reference_size = preview_size
if pet.has_method(&"get_pet_ground_offset"):
var ground_offset = pet.call(&"get_pet_ground_offset")
_expect(
ground_offset is Vector2 and _is_finite_vector2(ground_offset),
"get_pet_ground_offset() must return a finite Vector2"
)
if ground_offset is Vector2 and reference_size.x > 0.0 and reference_size.y > 0.0:
_expect(
ground_offset.x >= 0.0
and ground_offset.y >= 0.0
and ground_offset.x <= reference_size.x
and ground_offset.y <= reference_size.y,
"get_pet_ground_offset() must stay inside pet hit/preview size"
)
func _validate_optional_composite_pet_action(pet: Node) -> void:
var exposed_count := 0
for method_name in INTERACTIVE_PET_COMPOSITE_METHODS:
if pet.has_method(method_name):
exposed_count += 1
for signal_name in INTERACTIVE_PET_COMPOSITE_SIGNALS:
if pet.has_signal(signal_name):
exposed_count += 1
if exposed_count == 0:
return
for method_name in INTERACTIVE_PET_COMPOSITE_METHODS:
_expect(
pet.has_method(method_name),
"partial composite pet API: missing %s()" % method_name
)
for signal_name in INTERACTIVE_PET_COMPOSITE_SIGNALS:
_expect(
pet.has_signal(signal_name),
"partial composite pet API: missing signal %s" % signal_name
)
if exposed_count != INTERACTIVE_PET_COMPOSITE_METHODS.size() + INTERACTIVE_PET_COMPOSITE_SIGNALS.size():
return
var supported = pet.call(&"has_pet_composite_action", &"interact")
_expect(
supported is bool,
"has_pet_composite_action() must return bool"
)
func _validate_optional_positive_float(pet: Node, method_name: StringName) -> void:
if not pet.has_method(method_name):
return
var value := float(pet.call(method_name))
_expect(is_finite(value) and value > 0.0, "%s() must return a finite positive value" % method_name)
func _validate_pet_navigation(screen_root: Node, screen_id: String) -> void:
var navigation_roots: Array[Node] = []
for node in _all_nodes(screen_root):
if bool(node.get_meta(PET_NAVIGATION_ROOT_META, false)):
navigation_roots.append(node)
if navigation_roots.is_empty():
_error("%s must contain one pet navigation root" % screen_id)
return
_expect(navigation_roots.size() == 1, "%s must contain exactly one pet navigation root" % screen_id)
if navigation_roots.size() != 1:
return
var navigation_root := navigation_roots[0]
_expect(navigation_root is Node2D, "pet navigation root must inherit Node2D")
_expect(
int(navigation_root.get_meta(&"exchanger_pet_navigation_version", 0)) == 1,
"pet navigation root version must be 1"
)
var bounds_value = navigation_root.get_meta(&"exchanger_pet_roaming_bounds", null)
_expect(typeof(bounds_value) == TYPE_RECT2, "pet navigation root must declare Rect2 roaming bounds")
var bounds := bounds_value as Rect2 if typeof(bounds_value) == TYPE_RECT2 else Rect2()
_expect(
_is_finite_rect2(bounds) and bounds.size.x > 0.0 and bounds.size.y > 0.0,
"pet roaming bounds must be finite and positive"
)
var surfaces: Dictionary = {}
var climb_edges: Array[Dictionary] = []
var climb_ids: Dictionary = {}
for node in _all_nodes(navigation_root):
if bool(node.get_meta(PET_SURFACE_META, false)):
var surface_id := StringName(str(node.get_meta(&"exchanger_pet_surface_id", "")))
_expect(not surface_id.is_empty(), "pet surface id must not be empty")
_expect(not surfaces.has(surface_id), "duplicate pet surface id %s" % surface_id)
var from_x := float(node.get_meta(&"exchanger_pet_surface_from_x", NAN))
var to_x := float(node.get_meta(&"exchanger_pet_surface_to_x", NAN))
var surface_y := float(node.get_meta(&"exchanger_pet_surface_y", NAN))
_expect(
is_finite(from_x) and is_finite(to_x) and is_finite(surface_y) and from_x < to_x,
"pet surface %s must have finite from_x < to_x and y" % surface_id
)
if _is_finite_rect2(bounds):
_expect(
_rect_contains_inclusive(bounds, Vector2(from_x, surface_y))
and _rect_contains_inclusive(bounds, Vector2(to_x, surface_y)),
"pet surface %s must stay inside roaming bounds" % surface_id
)
var sit_points = node.get_meta(&"exchanger_pet_surface_sit_points", PackedFloat32Array())
var sit_type := typeof(sit_points)
_expect(
sit_type == TYPE_ARRAY or sit_type == TYPE_PACKED_FLOAT32_ARRAY,
"pet surface %s sit points must be an Array or PackedFloat32Array" % surface_id
)
if sit_type == TYPE_ARRAY or sit_type == TYPE_PACKED_FLOAT32_ARRAY:
for raw_x in sit_points:
var sit_x := float(raw_x)
_expect(
is_finite(sit_x) and sit_x >= from_x and sit_x <= to_x,
"pet surface %s sit point must be finite and inside the surface" % surface_id
)
if not surface_id.is_empty() and not surfaces.has(surface_id):
surfaces[surface_id] = {"from_x": from_x, "to_x": to_x, "y": surface_y}
if screen_id == "screen.home" and HOME_PET_SURFACE_VISIBILITY_BINDINGS.has(surface_id):
_expect(
str(node.get_meta(PET_SURFACE_VISIBILITY_BINDING_META, ""))
== str(HOME_PET_SURFACE_VISIBILITY_BINDINGS[surface_id]),
"pet surface %s must target visibility binding %s"
% [surface_id, HOME_PET_SURFACE_VISIBILITY_BINDINGS[surface_id]]
)
if bool(node.get_meta(PET_CLIMB_EDGE_META, false)):
var edge_id := StringName(str(node.get_meta(&"exchanger_pet_climb_edge_id", "")))
_expect(not edge_id.is_empty(), "pet climb edge id must not be empty")
_expect(not climb_ids.has(edge_id), "duplicate pet climb edge id %s" % edge_id)
if not edge_id.is_empty():
climb_ids[edge_id] = true
climb_edges.append({
"id": edge_id,
"from": StringName(str(node.get_meta(&"exchanger_pet_climb_from_surface_id", ""))),
"to": StringName(str(node.get_meta(&"exchanger_pet_climb_to_surface_id", ""))),
"x": float(node.get_meta(&"exchanger_pet_climb_x", NAN)),
"from_y": float(node.get_meta(&"exchanger_pet_climb_from_y", NAN)),
"to_y": float(node.get_meta(&"exchanger_pet_climb_to_y", NAN)),
"side": StringName(str(node.get_meta(&"exchanger_pet_climb_side", ""))),
})
_expect(not surfaces.is_empty(), "pet navigation root must contain at least one surface")
var default_surface := StringName(str(navigation_root.get_meta(&"exchanger_pet_default_surface_id", "")))
_expect(
not default_surface.is_empty() and surfaces.has(default_surface),
"pet navigation default surface must reference an existing surface"
)
for edge in climb_edges:
_validate_pet_climb_edge(edge, surfaces, bounds)
func _validate_pet_climb_edge(edge: Dictionary, surfaces: Dictionary, bounds: Rect2) -> void:
var edge_id: StringName = edge.id
var from_id: StringName = edge.from
var to_id: StringName = edge.to
_expect(not from_id.is_empty() and surfaces.has(from_id), "pet climb edge %s has unknown from surface" % edge_id)
_expect(not to_id.is_empty() and surfaces.has(to_id), "pet climb edge %s has unknown to surface" % edge_id)
var x := float(edge.x)
var from_y := float(edge.from_y)
var to_y := float(edge.to_y)
_expect(
is_finite(x) and is_finite(from_y) and is_finite(to_y) and not is_equal_approx(from_y, to_y),
"pet climb edge %s must have finite coordinates and non-zero height" % edge_id
)
_expect(edge.side == &"left" or edge.side == &"right", "pet climb edge %s side must be left or right" % edge_id)
if _is_finite_rect2(bounds):
_expect(
_rect_contains_inclusive(bounds, Vector2(x, from_y))
and _rect_contains_inclusive(bounds, Vector2(x, to_y)),
"pet climb edge %s must stay inside roaming bounds" % edge_id
)
if surfaces.has(from_id):
var from_surface: Dictionary = surfaces[from_id]
_expect(
x >= float(from_surface.from_x)
and x <= float(from_surface.to_x)
and is_equal_approx(from_y, float(from_surface.y)),
"pet climb edge %s must start on its from surface" % edge_id
)
if surfaces.has(to_id):
var to_surface: Dictionary = surfaces[to_id]
_expect(
x >= float(to_surface.from_x)
and x <= float(to_surface.to_x)
and is_equal_approx(to_y, float(to_surface.y)),
"pet climb edge %s must end on its to surface" % edge_id
)
func _is_finite_vector2(value: Vector2) -> bool:
return is_finite(value.x) and is_finite(value.y)
func _is_finite_rect2(value: Rect2) -> bool:
return _is_finite_vector2(value.position) and _is_finite_vector2(value.size)
func _rect_contains_inclusive(rect: Rect2, point: Vector2) -> bool:
return (
point.x >= rect.position.x
and point.y >= rect.position.y
and point.x <= rect.end.x
and point.y <= rect.end.y
)
func _validate_self_contained_visuals(
root: Node,
screen_id: String,
requires_nearest_filter: bool
) -> void:
_expect(
bool(root.get_meta(SELF_CONTAINED_VISUALS_META, false)),
"%s must declare exchanger_self_contained_visuals = true" % screen_id
)
_expect(root is Control, "%s root must be a Control" % screen_id)
if root is Control:
_expect((root as Control).theme != null, "%s root must apply its package ui_theme" % screen_id)
if requires_nearest_filter:
_expect(
(root as Control).texture_filter == CanvasItem.TEXTURE_FILTER_NEAREST,
"%s root must use Nearest when manifest UseNearestTextureFilter is true"
% screen_id
)
var background := root.get_node_or_null("ThemeBackground") as TextureRect
_expect(background != null, "%s must contain ThemeBackground TextureRect" % screen_id)
if background != null:
_expect(background.texture != null, "%s ThemeBackground must have a texture" % screen_id)
_expect(
root.get_node_or_null("BackgroundDecoration") != null,
"%s must contain BackgroundDecoration" % screen_id
)
func _validate_bindings(root: Node, screen: Dictionary, global_bindings: Dictionary, metadata_key: String) -> void:
var expected: Dictionary = {}
var screen_id := str(screen.get("BindingId", ""))
var supplemental: Dictionary = SUPPLEMENTAL_RUNTIME_BINDINGS.get(screen_id, {})
var optional_supplemental: Dictionary = OPTIONAL_SUPPLEMENTAL_RUNTIME_BINDINGS.get(screen_id, {})
for binding_value in screen.get("Bindings", []):
var binding: Dictionary = binding_value
expected[str(binding.get("Id", ""))] = binding
var found: Dictionary = {}
for node in _all_nodes(root):
if not node.has_meta(metadata_key):
continue
var binding_id := str(node.get_meta(metadata_key))
if binding_id.is_empty():
_error("Empty exchanger_binding_id in %s at %s" % [screen.get("BindingId", ""), root.get_path_to(node)])
continue
if found.has(binding_id):
_error("Duplicate binding %s inside %s" % [binding_id, screen.get("BindingId", "")])
continue
if global_bindings.has(binding_id):
_error("Binding %s is reused by %s and %s" % [binding_id, global_bindings[binding_id], screen.get("BindingId", "")])
found[binding_id] = node
global_bindings[binding_id] = screen.get("BindingId", "")
var allowed_types: Array = []
if expected.has(binding_id):
allowed_types = expected[binding_id].get("Types", [])
elif supplemental.has(binding_id):
allowed_types = supplemental[binding_id]
elif optional_supplemental.has(binding_id):
allowed_types = optional_supplemental[binding_id]
else:
_error("Binding %s is not declared for %s" % [binding_id, screen_id])
continue
if not allowed_types.is_empty() and not _is_any_class(node, allowed_types):
_error("Binding %s has type %s; expected %s" % [binding_id, node.get_class(), ", ".join(allowed_types)])
for binding_id in expected:
var binding: Dictionary = expected[binding_id]
if bool(binding.get("Required", true)) and not found.has(binding_id):
_error("Required binding %s is missing from %s" % [binding_id, screen.get("BindingId", "")])
for binding_id in supplemental:
if not found.has(binding_id):
_error("Required runtime binding %s is missing from %s" % [binding_id, screen_id])
func _validate_fixed_slot_defaults(root: Node, screen_id: String, metadata_key: String) -> void:
var bindings: Dictionary = {}
for node in _all_nodes(root):
if node.has_meta(metadata_key):
bindings[str(node.get_meta(metadata_key))] = node
if screen_id == "screen.home":
var speech_text := bindings.get("home.scroll.content.speech_text") as Label
if speech_text != null:
_expect(speech_text.autowrap_mode != TextServer.AUTOWRAP_OFF, "HOME speech text must wrap")
_expect(speech_text.clip_text, "HOME speech text must clip overflow")
_expect(
speech_text.horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER,
"HOME speech text must be horizontally centered"
)
_expect(
speech_text.vertical_alignment == VERTICAL_ALIGNMENT_CENTER,
"HOME speech text must be vertically centered"
)
return
if screen_id == "screen.cash_payment":
var cash_caption := root.get_node_or_null(
"SafeMargin/Content/Summary/Margin/Values/Caption"
) as Label
_expect(cash_caption != null, "CASH PAYMENT summary Caption is missing")
if cash_caption != null:
_expect(cash_caption.text == "К ВЫДАЧЕ", "CASH PAYMENT summary Caption must be К ВЫДАЧЕ")
return
if screen_id == "screen.card_amount":
for index in range(16):
var slot_id := "card_amount.content.amount_panel.margin.content.preset_grid.slot_%02d" % index
var slot := bindings.get(slot_id) as CanvasItem
if slot != null and index >= 8:
_expect(not slot.visible, "%s must be hidden by default" % slot_id)
var card_amount_value_id := "card_amount.content.reward_panel.content.value"
var card_amount_bonus_id := "card_amount.content.reward_panel.content.bonus"
var card_amount_value := bindings.get(card_amount_value_id) as Label
var card_amount_bonus := bindings.get(card_amount_bonus_id) as Label
_expect(
root.get_node_or_null("SafeMargin/Content/RewardPanel/Content/Value") == card_amount_value,
"%s must keep the canonical Value path" % card_amount_value_id
)
_expect(
root.get_node_or_null("SafeMargin/Content/RewardPanel/Content/Bonus") == card_amount_bonus,
"%s must use the canonical Bonus path" % card_amount_bonus_id
)
if card_amount_value != null and card_amount_bonus != null:
_expect(
card_amount_bonus.get_index() == card_amount_value.get_index() + 1,
"%s must be placed immediately after Value" % card_amount_bonus_id
)
_expect(
card_amount_bonus.position.x > card_amount_value.position.x,
"%s must be laid out to the right of Value" % card_amount_bonus_id
)
_expect(
card_amount_bonus.text == "БЕЗ БОНУСА",
"%s default text must be БЕЗ БОНУСА" % card_amount_bonus_id
)
return
if screen_id == "screen.card_custom_amount":
var clear_button := bindings.get(
"card_custom_amount.content.keypad_panel.content.keypad.clear_button"
) as Button
if clear_button != null:
_expect(clear_button.visible, "card custom amount clear button must be visible")
_expect(clear_button.text == "СТЕРЕТЬ", "card custom amount clear button text must be СТЕРЕТЬ")
_expect_visible_default(
bindings,
"card_custom_amount.content.keypad_panel.content.keypad.backspace_button",
false
)
var custom_amount_id := "card_custom_amount.content.amount_panel.margin.values.amount"
var custom_bonus_id := "card_custom_amount.content.amount_panel.margin.values.tokens"
var custom_amount := bindings.get(custom_amount_id) as Label
var custom_bonus := bindings.get(custom_bonus_id) as Label
_expect(
root.get_node_or_null("SafeMargin/Content/AmountPanel/Margin/Values/Amount") == custom_amount,
"%s must keep the canonical Amount path" % custom_amount_id
)
_expect(
root.get_node_or_null("SafeMargin/Content/AmountPanel/Margin/Values/Tokens") == custom_bonus,
"%s must keep the canonical Tokens path" % custom_bonus_id
)
if custom_amount != null and custom_bonus != null:
_expect(
custom_bonus.position.x > custom_amount.position.x,
"%s must be laid out to the right of Amount" % custom_bonus_id
)
_expect(
custom_bonus.text == "+ 50 ЖЕТОНОВ\nВ ПОДАРОК",
"%s default text must use the two-line bonus format" % custom_bonus_id
)
return
if screen_id != "screen.settings":
return
var short_text := bindings.get("settings.scroll.content.branding_panel.margin.content.short_text") as LineEdit
if short_text != null:
_expect(short_text.max_length == 48, "SETTINGS short text must be limited to 48 characters")
var branding_content_path := "SafeMargin/Scroll/Content/BrandingPanel/Margin/Content"
var application_name_label := root.get_node_or_null(
"%s/ApplicationNameLabel" % branding_content_path
) as Label
if application_name_label != null:
_expect(not application_name_label.visible, "SETTINGS application name label must be hidden")
var short_text_label := root.get_node_or_null(
"%s/ShortTextLabel" % branding_content_path
) as Label
if short_text_label != null:
_expect(
short_text_label.text == "СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ",
"SETTINGS short text label must be СВОЙ ТЕКСТ • ДО 48 ЗНАКОВ"
)
for hidden_binding_id in [
"settings.scroll.content.branding_panel.margin.content.application_name",
"settings.scroll.content.branding_panel.margin.content.application_name_error",
"settings.scroll.content.branding_preview_panel",
"settings.scroll.content.branding_preview_panel.margin.content.application_name",
"settings.scroll.content.branding_preview_panel.margin.content.short_text",
"settings.scroll.content.branding_preview_panel.margin.content.support_phone",
]:
_expect_visible_default(bindings, hidden_binding_id, false)
_expect_visible_default(
bindings,
"settings.scroll.content.service_inventory_panel",
false
)
var service_inventory_panel := bindings.get("settings.scroll.content.service_inventory_panel") as Control
if service_inventory_panel != null:
_expect(
is_equal_approx(service_inventory_panel.custom_minimum_size.y, 772.0),
"settings.scroll.content.service_inventory_panel minimum height must be 772"
)
var inventory_description_id := "settings.scroll.content.service_inventory_panel.margin.content.description"
var inventory_description := bindings.get(inventory_description_id) as Label
if inventory_description != null:
var canonical_inventory_description := root.get_node_or_null(
"SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/Description"
)
_expect(
canonical_inventory_description == inventory_description,
"%s must use the canonical Description path" % inventory_description_id
)
var inventory_enabled_id := "settings.scroll.content.service_inventory_panel.margin.content.inventory_enabled"
var inventory_enabled := bindings.get(inventory_enabled_id) as CheckButton
if inventory_enabled != null:
_expect(inventory_enabled.button_pressed, "%s must be enabled by default" % inventory_enabled_id)
_expect(
inventory_enabled.text == "УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ",
"%s text must be УЧИТЫВАТЬ ОСТАТОК ЖЕТОНОВ" % inventory_enabled_id
)
var canonical_inventory_enabled := root.get_node_or_null(
"SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/InventoryAccountingEnabled"
)
_expect(
canonical_inventory_enabled == inventory_enabled,
"%s must use the canonical InventoryAccountingEnabled path" % inventory_enabled_id
)
if inventory_description != null:
_expect(
inventory_enabled.get_index() == inventory_description.get_index() + 1,
"%s must be placed immediately after Description" % inventory_enabled_id
)
var purchase_limit_id := "settings.scroll.content.service_inventory_panel.margin.content.purchase_limit_enabled"
var purchase_limit := bindings.get(purchase_limit_id) as CheckButton
if purchase_limit != null:
_expect(purchase_limit.button_pressed, "%s must be enabled by default" % purchase_limit_id)
_expect(
purchase_limit.text == "НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА",
"%s text must be НЕ ПРОДАВАТЬ БОЛЬШЕ ОСТАТКА" % purchase_limit_id
)
var canonical_purchase_limit := root.get_node_or_null(
"SafeMargin/Scroll/Content/ServiceInventoryPanel/Margin/Content/PurchaseLimitEnabled"
)
_expect(
canonical_purchase_limit == purchase_limit,
"%s must use the canonical PurchaseLimitEnabled path" % purchase_limit_id
)
if inventory_enabled != null:
_expect(
purchase_limit.get_index() == inventory_enabled.get_index() + 1,
"%s must be placed immediately after InventoryAccountingEnabled" % purchase_limit_id
)
_expect_visible_default(
bindings,
"settings.scroll.content.menu_visibility_panel",
false
)
for binding_id in [
"settings.scroll.content.menu_visibility_panel.margin.content.show_stock_status",
"settings.scroll.content.menu_visibility_panel.margin.content.show_right_character",
"settings.scroll.content.menu_visibility_panel.margin.content.show_promotion_block",
"settings.scroll.content.menu_visibility_panel.margin.content.show_interactive_pet",
]:
var toggle := bindings.get(binding_id) as CheckButton
if toggle != null:
_expect(toggle.button_pressed, "%s must be enabled by default" % binding_id)
var footer := bindings.get("settings.footer") as PanelContainer
var save_and_exit := bindings.get("settings.footer.save_and_exit_button") as Button
var footer_clearance := bindings.get("settings.scroll.content.footer_clearance") as Control
var settings_scroll := bindings.get("settings.scroll") as ScrollContainer
var scroll_content := bindings.get("settings.scroll.content") as Control
if footer != null:
_expect(footer.visible, "settings.footer must be visible by default")
_expect(footer.get_parent() == root, "settings.footer must be a root sibling of SafeMargin")
_expect(
is_equal_approx(footer.anchor_top, 1.0) and is_equal_approx(footer.anchor_bottom, 1.0),
"settings.footer must stay anchored to the bottom edge"
)
if save_and_exit != null:
_expect(
save_and_exit.get_parent() == footer,
"settings.footer.save_and_exit_button must be a direct child of settings.footer"
)
_expect(save_and_exit.text == "СОХРАНИТЬ И ВЫЙТИ", "settings footer button text is invalid")
if footer_clearance != null:
_expect(
footer_clearance.get_parent() == scroll_content,
"settings.scroll.content.footer_clearance must be a direct child of scroll content"
)
_expect(
footer_clearance.custom_minimum_size.y >= 104.0,
"settings.scroll.content.footer_clearance must reserve the footer height"
)
if settings_scroll != null:
_expect(
settings_scroll.size_flags_vertical == Control.SIZE_SHRINK_BEGIN,
"settings.scroll must shrink from the bottom to avoid the fixed footer"
)
_expect(
settings_scroll.custom_minimum_size.y <= 1100.0,
"settings.scroll must leave room for the fixed footer at 720x1280"
)
_expect_visible_default(
bindings,
"settings.service_dialog_overlay",
false
)
var service_amount := bindings.get("settings.service_dialog_overlay.dialog.margin.content.amount") as SpinBox
if service_amount != null:
_expect(service_amount.min_value == 0.0, "SETTINGS service amount must allow zero")
_expect(service_amount.max_value >= 999999.0, "SETTINGS service amount must accept large inventory totals")
_expect(service_amount.suffix.is_empty(), "SETTINGS service amount must not reserve space for a suffix")
_expect_visible_default(
bindings,
"settings.scroll.content.bonus_panel.margin.content.rows.empty_state",
true
)
_expect_visible_default(
bindings,
"settings.scroll.content.card_options_panel.margin.content.preset_rows.empty_state",
false
)
_expect_visible_default(
bindings,
"settings.scroll.content.advertisement_playlist_panel.margin.content.rows.empty_state",
true
)
for index in range(16):
var bonus_id := "settings.scroll.content.bonus_panel.margin.content.rows.slot_%02d.bonus" % index
var bonus_field := bindings.get(bonus_id) as SpinBox
if bonus_field != null:
_expect(
bonus_field.suffix.is_empty(),
"%s must not have a suffix so multi-digit token counts remain visible" % bonus_id
)
_expect_visible_default(
bindings,
"settings.scroll.content.bonus_panel.margin.content.rows.slot_%02d" % index,
false
)
_expect_visible_default(
bindings,
"settings.scroll.content.card_options_panel.margin.content.preset_rows.slot_%02d" % index,
index < 8
)
for index in range(12):
_expect_visible_default(
bindings,
"settings.scroll.content.advertisement_playlist_panel.margin.content.rows.slot_%02d" % index,
false
)
func _expect_visible_default(bindings: Dictionary, binding_id: String, expected: bool) -> void:
var item := bindings.get(binding_id) as CanvasItem
if item != null:
_expect(item.visible == expected, "%s default visibility must be %s" % [binding_id, expected])
func _all_nodes(root: Node) -> Array[Node]:
var result: Array[Node] = [root]
var cursor := 0
while cursor < result.size():
var current := result[cursor]
for child in current.get_children():
result.append(child)
cursor += 1
return result
func _is_any_class(node: Node, allowed_types: Array) -> bool:
for type_value in allowed_types:
if node.is_class(str(type_value)):
return true
return false
func _contract_has_screen(contract: Dictionary, binding_id: String) -> bool:
for screen_value in contract.get("Screens", []):
if str(screen_value.get("BindingId", "")) == binding_id:
return true
return false
func _load_json(path: String) -> Dictionary:
if not FileAccess.file_exists(path):
_error("JSON file is missing: %s" % path)
return {}
var value = JSON.parse_string(FileAccess.get_file_as_string(path))
if not value is Dictionary:
_error("JSON root must be an object: %s" % path)
return {}
return value
func _argument_value(name: String, fallback: String) -> String:
var args := OS.get_cmdline_user_args()
var index := args.find(name)
if index >= 0 and index + 1 < args.size():
return args[index + 1]
return fallback
func _expect(condition: bool, message: String) -> void:
if not condition:
_error(message)
func _error(message: String) -> void:
_errors.append(message)
func _finish() -> void:
if _errors.is_empty():
print("[Theme Validator] OK: full-scene contract is valid.")
quit(0)
return
for message in _errors:
push_error("[Theme Validator] %s" % message)
print("[Theme Validator] FAILED: %d error(s)." % _errors.size())
quit(1)