From def224667d42e0f573ec6742eb9a928ed412b874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 21:23:43 +0200 Subject: [PATCH] Add artillery enemy with telegraphed AOE (#46) EnemyArtilleryComponent is a stationary siege piece (slow drift toward the fleet, deliberately no pursuit component) that fires at the fleet centroid on a long cooldown via a standalone ArtilleryShell - telegraph ring (~1.8s, growing radius plus a filling countdown arc) -> slow visible travel -> AOE explosion via Ship.take_damage() on every player_ships node inside, same all-or-nothing shield absorption as every other damage source. The shell is independent of the enemy that fired it (added to the tree root, like EnemyProjectile/Projectile) so the shooter dying mid-flight doesn't cancel an already-telegraphed shot, and the impact point is fixed at fire time - never re-aimed at where the fleet moves to afterward. Part of the #40 conquest-mechanic breakdown. --- ArtilleryEnemyDefinition.tres | 16 ++ ArtilleryShell.gd | 112 ++++++++++ ArtilleryShell.gd.uid | 1 + ArtilleryShell.tscn | 6 + EnemyArtilleryComponent.gd | 61 ++++++ EnemyArtilleryComponent.gd.uid | 1 + EnemyArtilleryComponent.tscn | 6 + EnemyDefinitions.gd | 3 +- tests/_artillery_telegraph_gas_preview.gd | 20 ++ tests/_artillery_telegraph_gas_preview.gd.uid | 1 + tests/_artillery_telegraph_gas_preview.tscn | 6 + tests/_artillery_telegraph_rocky_preview.gd | 20 ++ .../_artillery_telegraph_rocky_preview.gd.uid | 1 + tests/_artillery_telegraph_rocky_preview.tscn | 6 + tests/test_artillery_enemy.gd | 195 ++++++++++++++++++ tests/test_artillery_enemy.gd.uid | 1 + 16 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 ArtilleryEnemyDefinition.tres create mode 100644 ArtilleryShell.gd create mode 100644 ArtilleryShell.gd.uid create mode 100644 ArtilleryShell.tscn create mode 100644 EnemyArtilleryComponent.gd create mode 100644 EnemyArtilleryComponent.gd.uid create mode 100644 EnemyArtilleryComponent.tscn create mode 100644 tests/_artillery_telegraph_gas_preview.gd create mode 100644 tests/_artillery_telegraph_gas_preview.gd.uid create mode 100644 tests/_artillery_telegraph_gas_preview.tscn create mode 100644 tests/_artillery_telegraph_rocky_preview.gd create mode 100644 tests/_artillery_telegraph_rocky_preview.gd.uid create mode 100644 tests/_artillery_telegraph_rocky_preview.tscn create mode 100644 tests/test_artillery_enemy.gd create mode 100644 tests/test_artillery_enemy.gd.uid diff --git a/ArtilleryEnemyDefinition.tres b/ArtilleryEnemyDefinition.tres new file mode 100644 index 0000000..f47c597 --- /dev/null +++ b/ArtilleryEnemyDefinition.tres @@ -0,0 +1,16 @@ +[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3] + +[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"] +[ext_resource type="PackedScene" path="res://EnemyArtilleryComponent.tscn" id="2"] + +[resource] +script = ExtResource("1") +enemy_id = "artillery" +display_name = "Artillery" +max_hp = 8 +speed = 60.0 +acceleration = 120.0 +rotation_speed = 3.0 +hull_points = PackedVector2Array(0, -26, 18.4, -18.4, 26, 0, 18.4, 18.4, 0, 26, -18.4, 18.4, -26, 0, -18.4, -18.4) +glow_color = Color(1.0, 0.5, 0.2, 1) +component_scenes = Array[PackedScene]([ExtResource("2")]) diff --git a/ArtilleryShell.gd b/ArtilleryShell.gd new file mode 100644 index 0000000..2c8b1f9 --- /dev/null +++ b/ArtilleryShell.gd @@ -0,0 +1,112 @@ +class_name ArtilleryShell +extends Node2D + +# Self-contained telegraph -> travel -> explode sequence for the artillery +# enemy's attack (see EnemyArtilleryComponent). Lives independently of the +# enemy that fired it once launched - added to the tree root, exactly like +# EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't +# cancel a shot already telegraphed to the player. +# +# Shield interaction: a shield charge absorbs this hit entirely, same +# all-or-nothing semantics as every other damage source in this game +# (VesselShieldComponent.absorb_hit) - _explode() just calls +# Ship.take_damage() like everything else and lets that logic decide. + +signal finished + +@export var telegraph_time: float = 1.8 +@export var shell_speed: float = 180.0 +@export var aoe_radius: float = 220.0 +@export var damage: int = 3 +@export var color: Color = Color(1.0, 0.5, 0.2) + +enum Phase { TELEGRAPHING, TRAVELING, EXPLODING } + +const EXPLOSION_DURATION: float = 0.8 + +var phase: int = Phase.TELEGRAPHING +var target: Vector2 = Vector2.ZERO +var shell_pos: Vector2 = Vector2.ZERO +var telegraph_left: float = 0.0 +var explosion_time: float = 0.0 +var trail: PackedVector2Array = PackedVector2Array() + + +func _ready() -> void: + add_to_group("artillery_shells") + + +func launch(from: Vector2, at: Vector2) -> void: + target = at + shell_pos = from + telegraph_left = telegraph_time + phase = Phase.TELEGRAPHING + position = target + queue_redraw() + + +func _process(delta: float) -> void: + match phase: + Phase.TELEGRAPHING: + telegraph_left -= delta + if telegraph_left <= 0.0: + phase = Phase.TRAVELING + trail = PackedVector2Array([shell_pos]) + queue_redraw() + Phase.TRAVELING: + var to_target: Vector2 = target - shell_pos + var step: float = shell_speed * delta + if to_target.length() <= step: + shell_pos = target + _explode() + else: + shell_pos += to_target.normalized() * step + trail.append(shell_pos) + while trail.size() > 14: + trail.remove_at(0) + position = shell_pos + queue_redraw() + Phase.EXPLODING: + explosion_time -= delta + queue_redraw() + if explosion_time <= 0.0: + finished.emit() + queue_free() + + +func _explode() -> void: + phase = Phase.EXPLODING + explosion_time = EXPLOSION_DURATION + position = target + for ship in get_tree().get_nodes_in_group("player_ships"): + if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius: + ship.take_damage(damage) + + +func _draw() -> void: + match phase: + Phase.TELEGRAPHING: + var progress: float = clampf(1.0 - telegraph_left / maxf(telegraph_time, 0.001), 0.0, 1.0) + # Growing ring plus a filling countdown arc: the warning reads + # clearly without needing a number on screen. + var radius: float = lerpf(24.0, aoe_radius, progress) + _draw_ring(radius, 0.7) + draw_arc(Vector2.ZERO, aoe_radius + 14.0, -PI / 2.0, -PI / 2.0 + TAU * progress, 48, + Color(color, 0.85), 8.0, true) + Phase.TRAVELING: + for i in range(1, trail.size()): + var alpha: float = float(i) / trail.size() + draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) + draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35)) + NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0) + Phase.EXPLODING: + var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION + var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0)) + draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18)) + _draw_ring(eradius, 1.0 - eprogress) + + +func _draw_ring(radius: float, alpha: float) -> void: + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) + draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(1.0, 0.92, 0.86, 0.9 * alpha), 2.0, true) diff --git a/ArtilleryShell.gd.uid b/ArtilleryShell.gd.uid new file mode 100644 index 0000000..cfd76b3 --- /dev/null +++ b/ArtilleryShell.gd.uid @@ -0,0 +1 @@ +uid://cvxao0mjslo5x diff --git a/ArtilleryShell.tscn b/ArtilleryShell.tscn new file mode 100644 index 0000000..17ff161 --- /dev/null +++ b/ArtilleryShell.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://ArtilleryShell.gd" id="1"] + +[node name="ArtilleryShell" type="Node2D"] +script = ExtResource("1") diff --git a/EnemyArtilleryComponent.gd b/EnemyArtilleryComponent.gd new file mode 100644 index 0000000..43250c3 --- /dev/null +++ b/EnemyArtilleryComponent.gd @@ -0,0 +1,61 @@ +class_name EnemyArtilleryComponent +extends Node + +# Stationary siege piece: drifts slowly toward the fleet (deliberately no +# pursuit component - closing distance is incidental, not its purpose) and +# fires a slow, heavily telegraphed AOE shell on a long cooldown via a +# standalone ArtilleryShell, so an already-launched shot survives this +# enemy dying mid-flight, same as EnemyProjectile/Projectile already do. + +var capability_id: String = "artillery" + +@export var fire_cooldown: float = 6.0 +@export var fire_range: float = 900.0 +@export var telegraph_time: float = 1.8 +@export var shell_speed: float = 180.0 +@export var aoe_radius: float = 220.0 +@export var damage: int = 3 +@export var drift_speed: float = 20.0 +@export var color: Color = Color(1.0, 0.5, 0.2) + +var cooldown_left: float = 0.0 +var shell_scene: PackedScene = preload("res://ArtilleryShell.tscn") + + +func tick(delta: float) -> void: + var enemy: Node2D = get_parent() + _drift(enemy, delta) + + cooldown_left -= delta + if cooldown_left > 0.0: + return + + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + var centroid: Vector2 = fleet.get_centroid() + if enemy.global_position.distance_to(centroid) > fire_range: + return + + var shell: Node2D = shell_scene.instantiate() + shell.telegraph_time = telegraph_time + shell.shell_speed = shell_speed + shell.aoe_radius = aoe_radius + shell.damage = damage + shell.color = color + enemy.get_tree().root.add_child(shell) + shell.launch(enemy.global_position, centroid) + cooldown_left = fire_cooldown + + +func _drift(enemy: Node2D, delta: float) -> void: + # Local position throughout, same convention as EnemyPursuitComponent/ + # EnemyStandoffComponent: valid as long as the enemy's container and + # Fleet share a common origin, which every scene in this project does. + var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") + if fleet == null or fleet.ships.is_empty(): + return + var to_fleet: Vector2 = fleet.get_centroid() - enemy.position + if to_fleet.length() < 1.0: + return + enemy.position += to_fleet.normalized() * drift_speed * delta diff --git a/EnemyArtilleryComponent.gd.uid b/EnemyArtilleryComponent.gd.uid new file mode 100644 index 0000000..5ea4f97 --- /dev/null +++ b/EnemyArtilleryComponent.gd.uid @@ -0,0 +1 @@ +uid://b0woyjce8kl6x diff --git a/EnemyArtilleryComponent.tscn b/EnemyArtilleryComponent.tscn new file mode 100644 index 0000000..dcc574b --- /dev/null +++ b/EnemyArtilleryComponent.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://EnemyArtilleryComponent.gd" id="1"] + +[node name="EnemyArtilleryComponent" type="Node"] +script = ExtResource("1") diff --git a/EnemyDefinitions.gd b/EnemyDefinitions.gd index b641b60..ca44537 100644 --- a/EnemyDefinitions.gd +++ b/EnemyDefinitions.gd @@ -2,10 +2,11 @@ class_name EnemyDefinitions extends RefCounted const DEFAULT_ID: String = "chaser" -const ORDER: PackedStringArray = ["chaser", "sniper"] +const ORDER: PackedStringArray = ["chaser", "sniper", "artillery"] const DEFINITIONS: Dictionary = { "chaser": preload("res://ChaserEnemyDefinition.tres"), "sniper": preload("res://SniperEnemyDefinition.tres"), + "artillery": preload("res://ArtilleryEnemyDefinition.tres"), } diff --git a/tests/_artillery_telegraph_gas_preview.gd b/tests/_artillery_telegraph_gas_preview.gd new file mode 100644 index 0000000..c255b9a --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + + +func _ready() -> void: + GameState.planet_name = "Violet Tempest" + GameState.planet_id = "artillery_preview_gas" + GameState.planet_definition_id = "gas_giant" + GameState.surviving_ship_count = 3 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) + + var shell: Node = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot + shell.aoe_radius = 220.0 + surface.add_child(shell) + shell.launch(Vector2(-600, -600), Vector2.ZERO) + shell.telegraph_left = shell.telegraph_time * 0.45 + shell.set_process(false) + shell.queue_redraw() diff --git a/tests/_artillery_telegraph_gas_preview.gd.uid b/tests/_artillery_telegraph_gas_preview.gd.uid new file mode 100644 index 0000000..e5f7dff --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.gd.uid @@ -0,0 +1 @@ +uid://be13xddivhtr diff --git a/tests/_artillery_telegraph_gas_preview.tscn b/tests/_artillery_telegraph_gas_preview.tscn new file mode 100644 index 0000000..cbcb749 --- /dev/null +++ b/tests/_artillery_telegraph_gas_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_artillery_telegraph_gas_preview.gd" id="1"] + +[node name="ArtilleryTelegraphGasPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/_artillery_telegraph_rocky_preview.gd b/tests/_artillery_telegraph_rocky_preview.gd new file mode 100644 index 0000000..eaa8372 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd @@ -0,0 +1,20 @@ +extends Node2D + + +func _ready() -> void: + GameState.planet_name = "Kepler's Rest" + GameState.planet_id = "artillery_preview_rocky" + GameState.planet_definition_id = "rocky" + GameState.surviving_ship_count = 3 + var surface: Node = load("res://PlanetSurface.tscn").instantiate() + surface.get_node("SurfaceController").fade_duration = 0.0 + add_child(surface) + + var shell: Node = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 10.0 # frozen mid-countdown for the screenshot + shell.aoe_radius = 220.0 + surface.add_child(shell) + shell.launch(Vector2(-600, -600), Vector2.ZERO) + shell.telegraph_left = shell.telegraph_time * 0.45 + shell.set_process(false) + shell.queue_redraw() diff --git a/tests/_artillery_telegraph_rocky_preview.gd.uid b/tests/_artillery_telegraph_rocky_preview.gd.uid new file mode 100644 index 0000000..6571802 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.gd.uid @@ -0,0 +1 @@ +uid://dlaxr7it3582h diff --git a/tests/_artillery_telegraph_rocky_preview.tscn b/tests/_artillery_telegraph_rocky_preview.tscn new file mode 100644 index 0000000..c38c8e8 --- /dev/null +++ b/tests/_artillery_telegraph_rocky_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_artillery_telegraph_rocky_preview.gd" id="1"] + +[node name="ArtilleryTelegraphRockyPreview" type="Node2D"] +script = ExtResource("1") diff --git a/tests/test_artillery_enemy.gd b/tests/test_artillery_enemy.gd new file mode 100644 index 0000000..3fe97c0 --- /dev/null +++ b/tests/test_artillery_enemy.gd @@ -0,0 +1,195 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_artillery_enemy.gd +# Verifies: +# 1. the telegraph is visible (phase == TELEGRAPHING) for the whole +# window before any damage lands +# 2. a ship that leaves the blast radius before impact takes no damage; +# one that stays takes the full hit +# 3. a shielded ship inside the blast survives, losing exactly one charge +# 4. the impact point is fixed at fire time - moving the fleet after the +# shot is launched does not redirect it (tested through the real +# EnemyArtilleryComponent, since that's what could tempt "aim at the +# fleet's live position" instead of its position at fire time) + +# Resolved via load() inside _initialize(), NOT a top-level `const ... +# preload(...)` - a top-level preload in a script-mode ENTRY point (-s +# this_file.gd) evaluates during this file's own early compilation, which +# can happen before autoloads are registered. See test_capture_points.gd +# for the full explanation. +var ARTILLERY_SHELL_SCRIPT: Script + +var game_state: Node +var main: Node +var fleet: Node +var shell: Node +var ship_inside: Node +var ship_leaves: Node +var shield_ship: Node +var far_ship: Node +var artillery: Node +var art_component: Node +var live_shell: Node +var recorded_target: Vector2 = Vector2.ZERO +var impact_point: Vector2 = Vector2.ZERO +var travel_time: float = 0.0 +var stage2_wait: float = 0.0 +var stage5_wait: float = 0.0 +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + ARTILLERY_SHELL_SCRIPT = load("res://ArtilleryShell.gd") + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 + game_state.reset_progression() + game_state.rocks = 2000 + main = load("res://Main.tscn").instantiate() + root.add_child(main) + fleet = main.get_node("Fleet") + + +func _process(delta: float) -> bool: + elapsed += delta + match stage: + 0: + if elapsed < 0.1: + return false + main.get_node("EnemySpawner").set_process(false) + main.get_node("AsteroidField").set_process(false) + for a in get_nodes_in_group("asteroids"): + a.queue_free() + + impact_point = Vector2(5000, 0) + fleet.spawn_ships(1, impact_point) + ship_inside = fleet.ships[0] + ship_inside.position = impact_point + + fleet.add_ship(GameState.VESSEL_INTERCEPTOR) + ship_leaves = fleet.ships[fleet.ships.size() - 1] + ship_leaves.position = impact_point + Vector2(100, 0) + + fleet.add_ship(GameState.VESSEL_SHIELD) + shield_ship = fleet.ships[fleet.ships.size() - 1] + shield_ship.position = impact_point + Vector2(-100, 0) + + var origin: Vector2 = impact_point + Vector2(400, 400) + travel_time = origin.distance_to(impact_point) / 300.0 + shell = load("res://ArtilleryShell.tscn").instantiate() + shell.telegraph_time = 1.0 + shell.shell_speed = 300.0 + shell.aoe_radius = 220.0 + shell.damage = 3 + main.add_child(shell) + shell.launch(origin, impact_point) + + elapsed = 0.0 + stage = 1 + return false + 1: + # Mid-telegraph: nobody hurt yet, still visibly telegraphing. + if elapsed < shell.telegraph_time * 0.8: + return false + if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: + return _fail("shell left the telegraph phase too early") + if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ + or not is_instance_valid(shield_ship): + return _fail("damage landed before the telegraph window finished") + + # Ship B clears the blast radius now, well before impact. + ship_leaves.position = impact_point + Vector2(2000, 0) + # Captured once, as a plain float: the shell frees itself once + # it resolves, so re-reading shell.telegraph_time from inside + # the wait condition below (evaluated every frame) would touch + # a freed object on any frame after that. + stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 + elapsed = 0.0 + stage = 2 + return false + 2: + # Enough time for the remaining telegraph, travel, and the full + # explosion visual to resolve. + if elapsed < stage2_wait: + return false + if is_instance_valid(ship_inside): + return _fail("a ship that stayed inside the blast radius survived") + if not is_instance_valid(ship_leaves): + return _fail("a ship that left the blast radius before impact was still hit") + if not is_instance_valid(shield_ship): + return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit") + if shield_ship.shield_charges != 0: + return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" % + shield_ship.shield_charges) + print("stayed=destroyed left=survived shielded=absorbed-one-charge") + + # --- Part B: the impact point does not home onto a fleet that + # moves after the shot is fired --------------------------------- + fleet.spawn_ships(1, Vector2(9000, 0)) + far_ship = fleet.ships[0] + artillery = load("res://Enemy.tscn").instantiate() + artillery.enemy_id = "artillery" + artillery.position = far_ship.position + Vector2(300, 0) + main.add_child(artillery) + art_component = artillery.get_component("artillery") + art_component.cooldown_left = 0.0 + art_component.telegraph_time = 0.3 + art_component.fire_cooldown = 999.0 # only the one shot for this check + elapsed = 0.0 + stage = 3 + return false + 3: + if elapsed < 0.15: + return false + # The shell is added to the tree root (a sibling of main, not + # its child - see EnemyArtilleryComponent.tick()), so it has to + # be found via its group, not main.get_children(). + var shells: Array = get_nodes_in_group("artillery_shells") + if shells.is_empty(): + return _fail("artillery enemy did not fire when in range and off cooldown") + live_shell = shells[0] + recorded_target = live_shell.target + + far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target + elapsed = 0.0 + stage = 4 + return false + 4: + # Check the target field WHILE the shell is still alive - it + # frees itself right after exploding, so this has to happen + # before that, not after. + if elapsed < 0.3: + return false + if not is_instance_valid(live_shell): + return _fail("shell resolved implausibly fast - timing assumption broke") + if live_shell.target != recorded_target: + return _fail("shell target changed after the fleet moved - it homed instead of staying fixed") + # Captured once, as a plain float: far_ship's own weapon can + # destroy the artillery enemy (and with it art_component) well + # before this wait ends, so the condition below must not keep + # re-reading a property off a node that might already be freed. + stage5_wait = art_component.telegraph_time + (300.0 / 180.0) + 1.5 + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < stage5_wait: + return false + if not is_instance_valid(far_ship): + return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred") + print("shell held its fire-time target and did not home onto the fleeing ship") + print("TEST_ARTILLERY_ENEMY: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_artillery_enemy.gd.uid b/tests/test_artillery_enemy.gd.uid new file mode 100644 index 0000000..33da4a1 --- /dev/null +++ b/tests/test_artillery_enemy.gd.uid @@ -0,0 +1 @@ +uid://cv3lvd01j4kp