Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle SimpleSpawner compatibility #290

Merged
merged 2 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions addons/block_code/serialization/block_serialization.gd
Original file line number Diff line number Diff line change
@@ -1,13 +1,71 @@
@tool
extends Resource

const BlockSerialization = preload("res://addons/block_code/serialization/block_serialization.gd")

@export var name: StringName
@export var name: StringName:
set = _set_name
@export var children: Array[BlockSerialization]
@export var arguments: Dictionary # String, ValueBlockSerialization
@export var arguments: Dictionary: # String, ValueBlockSerialization
set = _set_arguments


func _init(p_name: StringName = &"", p_children: Array[BlockSerialization] = [], p_arguments: Dictionary = {}):
name = p_name
children = p_children
arguments = p_arguments


# Block name and arguments backwards compatibility handling.
const _renamed_blocks: Dictionary = {
&"simplespawner_set_spawn_frequency": &"simplespawner_set_spawn_period",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess one could extend this so that the values are not just a string but a tuple of:

  • new block name
  • map from old parameter name to new parameter name

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, something like that. An issue here is that we don't control the order that Godot sets the property in. So, name might not be migrated in this case. Here's an untested thing I came up with:

diff --git a/addons/block_code/serialization/block_serialization.gd b/addons/block_code/serialization/block_serialization.gd
index 4332b73..46ffd4b 100644
--- a/addons/block_code/serialization/block_serialization.gd
+++ b/addons/block_code/serialization/block_serialization.gd
@@ -6,7 +6,8 @@ const BlockSerialization = preload("res://addons/block_code/serialization/block_
 @export var name: StringName:
 	set = _set_name
 @export var children: Array[BlockSerialization]
-@export var arguments: Dictionary  # String, ValueBlockSerialization
+@export var arguments: Dictionary:  # String, ValueBlockSerialization
+	set = _set_arguments
 
 
 func _init(p_name: StringName = &"", p_children: Array[BlockSerialization] = [], p_arguments: Dictionary = {}):
@@ -30,3 +31,35 @@ func _set_name(value):
 			EditorInterface.mark_scene_as_unsaved()
 	else:
 		name = value
+
+
+const _renamed_arguments: Dictionary = {
+	&"simplespawner_set_spawn_period": {
+		"new_frequency": "new_period",
+	},
+}
+
+
+func _set_arguments(value):
+	if not value is Dictionary:
+		return
+
+	var renamed_args = _renamed_arguments.get(name)
+	if not renamed_args:
+		# Try with the new block name if it hasn't been migrated yet.
+		var new_block_name = _renamed_blocks.get(name)
+		if new_block_name:
+			renamed_args = _renamed_arguments.get(new_block_name)
+
+	if renamed_args:
+		value = value.duplicate()
+		for old_arg in renamed_args.keys():
+			if not old_arg in value:
+				continue
+
+			var new_arg = renamed_args[old_arg]
+			print("Migrating block %s argument %s to new name %s" % [name, old_arg, new_arg])
+			value[new_arg] = value[old_arg]
+			value.erase(old_arg)
+
+	arguments = value

}


func _set_name(value):
var new_name = _renamed_blocks.get(value)
if new_name:
print("Migrating block %s to new name %s" % [value, new_name])
name = new_name
if Engine.is_editor_hint():
EditorInterface.mark_scene_as_unsaved()
else:
name = value
Comment on lines +25 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm happy with just this as a place to start. Two thoughts for the distant future:

  • There's a danger that the old name is now unusable in this case, so we might want to couple it with a version number or something.
  • This requires that we maintain a list of renamed blocks in the core plugin code. That's an issue for blocks added outside of the core plugin. Those aren't a thing at the moment, but a different (albeit fussier) approach could be a renamed_from field in block_definition.gd.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, this only handles pure renames. If you changed the arguments or something like that, this wouldn't work. We do have a version number in the higher level BlockScriptSerialization, but it's not available here right now.

@wjt poked at adding aliases for blocks in #284. I didn't look closely at his approach there, but it was my initial suggestion 😆

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately I actually did change the argument name in the "set frequency period" block...



const _renamed_arguments: Dictionary = {
&"simplespawner_set_spawn_period":
{
"new_frequency": "new_period",
},
}


func _set_arguments(value):
if not value is Dictionary:
return

var renamed_args = _renamed_arguments.get(name)
if not renamed_args:
# Try with the new block name if it hasn't been migrated yet.
var new_block_name = _renamed_blocks.get(name)
if new_block_name:
renamed_args = _renamed_arguments.get(new_block_name)

if renamed_args:
var changed: bool = false
value = value.duplicate()
for old_arg in renamed_args.keys():
if not old_arg in value:
continue

var new_arg = renamed_args[old_arg]
print("Migrating block %s argument %s to new name %s" % [name, old_arg, new_arg])
value[new_arg] = value[old_arg]
value.erase(old_arg)
changed = true

if changed and Engine.is_editor_hint():
EditorInterface.mark_scene_as_unsaved()

arguments = value
57 changes: 55 additions & 2 deletions addons/block_code/serialization/value_block_serialization.gd
Original file line number Diff line number Diff line change
@@ -1,9 +1,62 @@
@tool
extends Resource

@export var name: StringName
@export var arguments: Dictionary # String, ValueBlockSerialization
@export var name: StringName:
set = _set_name
@export var arguments: Dictionary: # String, ValueBlockSerialization
set = _set_arguments


func _init(p_name: StringName = &"", p_arguments: Dictionary = {}):
name = p_name
arguments = p_arguments


# Block name and arguments backwards compatibility handling.
const _renamed_blocks: Dictionary = {
&"simplespawner_get_spawn_frequency": &"simplespawner_get_spawn_period",
}


func _set_name(value):
var new_name = _renamed_blocks.get(value)
if new_name:
print("Migrating block %s to new name %s" % [value, new_name])
name = new_name
if Engine.is_editor_hint():
EditorInterface.mark_scene_as_unsaved()
else:
name = value


const _renamed_arguments: Dictionary = {}


func _set_arguments(value):
if not value is Dictionary:
return

var renamed_args = _renamed_arguments.get(name)
if not renamed_args:
# Try with the new block name if it hasn't been migrated yet.
var new_block_name = _renamed_blocks.get(name)
if new_block_name:
renamed_args = _renamed_arguments.get(new_block_name)

if renamed_args:
var changed: bool = false
value = value.duplicate()
for old_arg in renamed_args.keys():
if not old_arg in value:
continue

var new_arg = renamed_args[old_arg]
print("Migrating block %s argument %s to new name %s" % [name, old_arg, new_arg])
value[new_arg] = value[old_arg]
value.erase(old_arg)
changed = true

if changed and Engine.is_editor_hint():
EditorInterface.mark_scene_as_unsaved()

arguments = value
37 changes: 37 additions & 0 deletions addons/block_code/simple_spawner/simple_spawner.gd
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,40 @@ static func setup_custom_blocks():
block_list.append(block_definition)

BlocksCatalog.add_custom_blocks(_class_name, block_list, [], {})


# Backwards compatibility handling
func _get_property_list() -> Array[Dictionary]:
return [
{
# spawn_frequency was renamed to spawn_period
"name": "spawn_frequency",
"class_name": &"",
"type": TYPE_FLOAT,
"hint": PROPERTY_HINT_NONE,
"hint_string": "",
"usage": PROPERTY_USAGE_NONE,
},
]
Comment on lines +174 to +185
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, interesting!



func _get(property: StringName) -> Variant:
match property:
"spawn_frequency":
return spawn_period
_:
return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm somewhat disturbed that returning null means "handle the property normally" (ergo a property can't just be null), but, indeed. This is fun!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, null as sentinel and value is always fun.



func _set(property: StringName, value: Variant) -> bool:
match property:
"spawn_frequency":
print("Migrating SimpleSpawner spawn_frequency property to new name spawn_period")
spawn_period = value
_:
return false

# Any migrated properties need to be resaved.
if Engine.is_editor_hint():
EditorInterface.mark_scene_as_unsaved()
return true
66 changes: 66 additions & 0 deletions tests/data/simple_spawner_compat.tscn
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
[gd_scene load_steps=14 format=3 uid="uid://br3g0jiuidqmb"]

[ext_resource type="Script" path="res://addons/block_code/simple_spawner/simple_spawner.gd" id="1_liuhg"]
[ext_resource type="Script" path="res://addons/block_code/block_code_node/block_code.gd" id="2_xxmhe"]
[ext_resource type="Script" path="res://addons/block_code/serialization/block_serialization_tree.gd" id="3_4xc0e"]
[ext_resource type="Script" path="res://addons/block_code/serialization/block_serialization.gd" id="4_ao2cd"]
[ext_resource type="Script" path="res://addons/block_code/serialization/value_block_serialization.gd" id="5_387nk"]
[ext_resource type="Script" path="res://addons/block_code/serialization/block_script_serialization.gd" id="5_a3p2k"]
[ext_resource type="Script" path="res://addons/block_code/code_generation/variable_definition.gd" id="6_1vau2"]

[sub_resource type="Resource" id="Resource_kt8ln"]
script = ExtResource("4_ao2cd")
name = &"simplespawner_set_spawn_frequency"
children = Array[ExtResource("4_ao2cd")]([])
arguments = {
"new_frequency": 10.0
}

[sub_resource type="Resource" id="Resource_4x50y"]
script = ExtResource("5_387nk")
name = &"simplespawner_get_spawn_frequency"
arguments = {}

[sub_resource type="Resource" id="Resource_ar5am"]
script = ExtResource("4_ao2cd")
name = &"print"
children = Array[ExtResource("4_ao2cd")]([])
arguments = {
"text": SubResource("Resource_4x50y")
}

[sub_resource type="Resource" id="Resource_yeqkk"]
script = ExtResource("4_ao2cd")
name = &"ready"
children = Array[ExtResource("4_ao2cd")]([SubResource("Resource_kt8ln"), SubResource("Resource_ar5am")])
arguments = {}

[sub_resource type="Resource" id="Resource_o6xkk"]
script = ExtResource("3_4xc0e")
root = SubResource("Resource_yeqkk")
canvas_position = Vector2(54, 47)

[sub_resource type="Resource" id="Resource_u6cb0"]
script = ExtResource("5_a3p2k")
script_inherits = "SimpleSpawner"
block_serialization_trees = Array[ExtResource("3_4xc0e")]([SubResource("Resource_o6xkk")])
variables = Array[ExtResource("6_1vau2")]([])
generated_script = "extends SimpleSpawner


func _ready():
do_set_spawn_frequency(10)
print((spawn_frequency))

"
version = 0

[node name="Root" type="Node2D"]

[node name="SimpleSpawner" type="Node2D" parent="."]
script = ExtResource("1_liuhg")
spawn_frequency = 5.0

[node name="BlockCode" type="Node" parent="SimpleSpawner"]
script = ExtResource("2_xxmhe")
block_script = SubResource("Resource_u6cb0")
150 changes: 150 additions & 0 deletions tests/test_compatibility.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
extends GutTest
## Tests for scene file backwards compatibility


func get_scene_state_node_index(state: SceneState, name: String) -> int:
for idx in state.get_node_count():
if state.get_node_name(idx) == name:
return idx
return -1


func get_scene_state_node_prop_index(state: SceneState, node_idx: int, name: String) -> int:
for prop_idx in state.get_node_property_count(node_idx):
if state.get_node_property_name(node_idx, prop_idx) == name:
return prop_idx
return -1


func _get_block_names_recursive(block: Resource, names: Array[String]):
names.append(block.name)
if "children" in block:
for child in block.children:
_get_block_names_recursive(child, names)
for value in block.arguments.values():
if not value is Resource:
continue
if not value.script:
continue
if value.script.resource_path != "res://addons/block_code/serialization/value_block_serialization.gd":
continue
_get_block_names_recursive(value, names)


func get_block_script_block_names(block_script: Resource) -> Array[String]:
var names: Array[String]
for tree in block_script.block_serialization_trees:
_get_block_names_recursive(tree.root, names)
return names


func _get_block_argument_names_recursive(block: Resource, names: Array[String]):
names.append_array(block.arguments.keys())
if "children" in block:
for child in block.children:
_get_block_argument_names_recursive(child, names)
for value in block.arguments.values():
if not value is Resource:
continue
if not value.script:
continue
if value.script.resource_path != "res://addons/block_code/serialization/value_block_serialization.gd":
continue
_get_block_argument_names_recursive(value, names)


func get_block_script_argument_names(block_script: Resource) -> Array[String]:
var names: Array[String]
for tree in block_script.block_serialization_trees:
_get_block_argument_names_recursive(tree.root, names)
return names


func test_simple_spawner():
const old_block_names: Array[String] = [
"simplespawner_get_spawn_frequency",
"simplespawner_set_spawn_frequency",
]

const new_block_names: Array[String] = [
"simplespawner_get_spawn_period",
"simplespawner_set_spawn_period",
]

const old_argument_names: Array[String] = [
"new_frequency",
]

const new_argument_names: Array[String] = [
"new_period",
]

var scene: PackedScene = load("res://tests/data/simple_spawner_compat.tscn")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, it's marvellous to have an example of a test with a scene file! Thank you for writing tests :)

assert_not_null(scene)
assert_true(scene.can_instantiate(), "Scene should be instantiable")

var scene_state := scene.get_state()
var spawner_idx := get_scene_state_node_index(scene_state, "SimpleSpawner")
assert_gt(spawner_idx, -1, "SimpleSpawner node could not be found")

# The packed SimpleSpawner node should have a simple_frequency
# property but no simple_period property.
var frequency_idx := get_scene_state_node_prop_index(scene_state, spawner_idx, "spawn_frequency")
var period_idx := get_scene_state_node_prop_index(scene_state, spawner_idx, "spawn_period")
assert_gt(frequency_idx, -1, "Old SimpleSpawner node should have spawn_frequency property")
assert_lt(period_idx, 0, "Old SimpleSpawner node should not have spawn_period property")

var packed_frequency = scene_state.get_node_property_value(spawner_idx, frequency_idx)
assert_typeof(packed_frequency, TYPE_FLOAT)
assert_eq(packed_frequency, 5.0)

var block_code_idx := get_scene_state_node_index(scene_state, "BlockCode")
assert_gt(block_code_idx, -1, "BlockCode node could not be found")
var block_script_idx := get_scene_state_node_prop_index(scene_state, block_code_idx, "block_script")
assert_gt(block_script_idx, -1, "BlockCode node block_script could not be found")
var packed_block_script = scene_state.get_node_property_value(block_code_idx, block_script_idx)
assert_typeof(packed_block_script, TYPE_OBJECT)
assert_eq("Resource", packed_block_script.get_class())
assert_eq(packed_block_script.script.resource_path, "res://addons/block_code/serialization/block_script_serialization.gd")

# Unlike Nodes, Resources are created immediately when loading the
# scene, so the block names and arguments should already be migrated.
var packed_block_names := get_block_script_block_names(packed_block_script)
for name in old_block_names:
assert_does_not_have(packed_block_names, name, "Block script should not have old name %s" % name)
for name in new_block_names:
assert_has(packed_block_names, name, "Block script should have new name %s" % name)

var packed_argument_names := get_block_script_argument_names(packed_block_script)
for name in old_argument_names:
assert_does_not_have(packed_argument_names, name, "Block script should not have old argument %s" % name)
for name in new_argument_names:
assert_has(packed_argument_names, name, "Block script should have new argument %s" % name)

# Instantiate the scene and check the Node properties.
var root := scene.instantiate()
assert_not_null(root)
autoqfree(root)

var spawner: SimpleSpawner = root.get_node("SimpleSpawner")
assert_eq(spawner.spawn_frequency, 5.0)
assert_eq(spawner.spawn_period, 5.0)

# Pack the scene and check that the old properties won't be saved.
var err: Error = scene.pack(root)
assert_eq(err, OK, "Packing scene should not cause an error")

scene_state = scene.get_state()
spawner_idx = get_scene_state_node_index(scene_state, "SimpleSpawner")
assert_gt(spawner_idx, -1, "SimpleSpawner node could not be found")

# The newly packed SimpleSpawner node should have a simple_period
# property but no simple_frequency property.
period_idx = get_scene_state_node_prop_index(scene_state, spawner_idx, "spawn_period")
frequency_idx = get_scene_state_node_prop_index(scene_state, spawner_idx, "spawn_frequency")
assert_gt(period_idx, -1, "New SimpleSpawner node should have spawn_period property")
assert_lt(frequency_idx, 0, "New SimpleSpawner node should not have spawn_frequency property")

var packed_period = scene_state.get_node_property_value(spawner_idx, period_idx)
assert_typeof(packed_period, TYPE_FLOAT)
assert_eq(packed_period, 5.0)